Commit 3964efb
fix(pm): post-stamped refuses a quoted stamp that names no real instant (#18292)
Fixes #18289
## The defect
The quoted-stamp shape rule is the protocol's stamp regex, and a regex
counts digits: `2026-13-45T99:99Z` satisfies every class in it. The
direction rule then let it through in the quiet direction — `stampSpan`
asks `Date.parse`, which answers NaN, so the span is null, so "is this
later than now" is `false`. Not future, therefore not refused.
Measured on `origin/main` at `499f7f9e`, verbatim:
```
$ node scripts/pm/post-stamped.mjs --dry-run --comment=18289 --file=repro.md # EXIT=0
post-stamped: DRY RUN — nothing was written. 0 token(s) substituted with `2026-09-15T09:11Z`, 1 quoted stamp(s) rendered verbatim. Target would be #18289 (comment).
--- stdout, the body it would have written ---
The board was read at 2026-13-45T99:99Z — month 13, day 45, 99:99.
```
A stamp no clock could ever have shown, on the board as a reading.
### The half the card asked me to go and measure
The card asked how `stampSpan` parses and whether normalisation counts
as real. Measured on this runtime, `Date.parse` has **two** ways of not
meaning what the digits say, and only the first is loud:
| | shape | `Date.parse` | before this PR |
|---|---|---|---|
| `2026-13-45T99:99Z`, `2026-01-01T99:00Z`, `…T00:99Z`, `2026-00-01…`,
`…T00:00:60Z` | passes | **NaN** | rendered verbatim, exit 0 |
| `2026-04-31T00:00Z` | passes | **2026-05-01**, rolled forward |
rendered verbatim, exit 0 |
| `2027-02-29T00:00Z` | passes | **2027-03-01**, rolled forward |
refused, but as `quoted-in-the-future` — the wrong reason, and only
because 2027 happens to be ahead of the clock |
| `2026-09-10T24:00Z` | passes | **2026-09-11T00:00**, rolled forward |
rendered verbatim |
So the rolled-over half is live today and is **not** covered by a NaN
check: `2026-04-31T00:00Z` parses, sits in the past, and is not the date
its digits spell. Reproduced on the unpatched tool — same exit 0, same
"1 quoted stamp(s) rendered verbatim".
## What changed — `scripts/pm/post-stamped.mjs` only
1. **`stampRealInstant`** — one rule for both halves: re-render the
instant the value parsed to, at the stamp's own grain, and require the
bytes back. A value that round-trips to a different date is not a
reading of the instant it names. It returns the date it rolled over to,
so the refusal can print it; `rolledTo` is null for the NaN half and
never the input, so a caller cannot report a rollover that did not
happen.
2. **One new refusal, `quoted-no-such-instant`, in `stampRefusals`
beside the direction rule** — the same scan, no second pass, so exactly
one rule still decides what a quoted stamp is. The three fire one at a
time: shape, then calendar, then direction. Printed in the existing
`post-stamped: REFUSED` row form with the offending span, on `--dry-run`
and on a live write alike, at the same `EXIT_REFUSED` as the other
refusals (the live path refuses in `renderBody`, before any request —
measured: exit 2, empty stdout, no network reached).
3. **`quotedRouteClosed`** — the positional and mixed refusals hand the
seat the quoted token with the typed stamp filled in, so the new closure
would have made them prescribe a refusal for an impossible stamp: a
refusal text prescribing a refused remedy. Both now read one predicate
that shuts the offer off for either reason instead of the future alone.
This changes refusal **text** only, never which refusals fire. Called
out as a deviation below.
4. **Prose** — the header boundary paragraph and the `--help` usage said
the quoted payload was minute-grained; the accepted shape has always
taken `YYYY-MM-DDThh:mm:ssZ` too. Both corrected in the same edit,
beside the direction rule's prose, plus a new header section on the
calendar rule.
Nothing is narrowed. Every value the tool accepted before — both grains,
a leap day in a leap year, whitespace inside the declaration —
round-trips by construction, because a stamp the calendar has is exactly
what `Date.parse` returns unchanged.
## Reverse verification
Ablation from the **committed** state, absolute paths, `trap … EXIT INT
TERM`, restore proved by hash and by an empty `git diff HEAD` — never by
an exit code. There is no build step: `node scripts/pm/post-stamped.mjs`
reads the source file, so the on-disk hash *is* what ran.
```
BASE blob 26e7f6c HEAD blob 6cd7b90
ablated: git hash-object = 26e7f6c… (= BASE) grep -c stampRealInstant = 0 grep -c quoted-no-such-instant = 0
restored: git hash-object = 6cd7b90… (= HEAD) grep -c stampRealInstant = 10 grep -c quoted-no-such-instant = 14
git diff HEAD: EMPTY git status --porcelain: EMPTY
```
| | ablated (base blob on disk) | restored (HEAD blob on disk) |
|---|---|---|
| `{{WAS:2026-13-45T99:99Z}}` | **exit 0**, "1 quoted stamp(s) rendered
verbatim", body printed with the impossible stamp in it | **exit 2**,
`[quoted-no-such-instant]`, stdout empty |
| `{{WAS:2026-04-31T00:00Z}}` | **exit 0**, same line, rendered verbatim
| **exit 2**, `[quoted-no-such-instant]` naming the rollover to
`2026-05-01T00:00Z` |
| `--self-test` | 152 cases across 9 batteries | 186 cases across 10
batteries |
Predicted direction was RED — a refusal appears where none did. Observed
exactly that: no reversal, no diagnostics-count change.
## Self-test
152 cases / 9 batteries → **186 / 10**. New battery `the calendar rule:
a stamp shaped like an instant the calendar does not have`, floored at
its real count of 34; `SELF_TEST_BATTERY_FLOOR` raised 9 → 10 so
deleting the battery outright is still caught.
Cases the card named: an impossible month, day, hour and minute each; a
zero month and day; an impossible second at the seconds grain; the 31st
of a 30-day month; a 29 February in a non-leap year. Controls, none
narrowed: a real instant at the minute grain and at the seconds grain
both still render; the act's own minute still accepted per the boundary
rule; whitespace inside the declaration is no escape. Plus the pins that
keep the rules apart — a shape failure is not also a calendar problem,
and a calendar failure is not also a direction one.
On the leap-day control the card asked for: `2028-02-29` is pinned real
by the calendar rule directly (`stampRealInstant(…).real === true`, and
`quoted-no-such-instant` is **not** among its refusals), because against
the battery's fixed clock of 2026-09-10 it is refused by the direction
rule alone — pinning it as end-to-end accepted would have pinned the
wrong thing. A leap day already past, `2024-02-29`, is the end-to-end
control.
## Gates
Union re-run on the **merge commit** `6fbb74e8` after merging
`origin/main` (`bda3a60c`) — merged, never rebased; the incoming PR
touches two files disjoint from this diff, the merge was clean, and no
regeneration debt was recorded.
- `node scripts/pm/dispatch-gates.mjs --commands
scripts/pm/post-stamped.mjs` derived **32** families; **all 32 run, all
exit 0**.
- `node scripts/pm/dispatch-gates.mjs --ran …` (written as `command ::
exit N`): **32 derived accounted for — 32 run, 0 NOT-MEASURED** (a
derived zero), exit 0.
- `pnpm check:pm-dispatch-gates` (gate source, run detached, waited on
with `tail --pid`, exit captured to a file by the detached shell):
**exit 0**, 1730 cases, 437.6s self-reported / 438s wall.
- `pnpm check:pm-post-stamped` (the tool's own self-test): exit 0, 186
cases across 10 batteries.
- `node scripts/check-self-test-wired.mjs`: exit 0. ESLint on the file:
exit 0, no output.
- Control-byte self-scan beyond `check:nul-bytes`: no hits.
Outside the derived total and not claimed here: the artifact-roster,
wide-population, pending-changeset and path-scheduled families the tool
prints under their own headings. Those are CI's.
## skip-changeset
Nothing published. The one file is under `scripts/pm/**`, which ships
from no package's `files[]`.
## Acceptance notes
**To file (out of scope here — a different rule, a different scan).**
The same `stampSpan` rollover reaches the **read** side of this file, in
the unread-knock check rather than the quoted-stamp rule. A stored body
whose newest stamp is an impossible date makes `lastWriteStamp` return
an instant later than the text names, so the refresh window starts late
and a knock inside the gap is classified `none-newer` — the refresh
proceeds and the knock is voided. Measured:
```
storedBody newest stamp text : 2026-04-31T00:00Z
lastWriteStamp instant : 2026-05-01T00:00:00.000Z
a knock at 2026-04-30T12:00Z : none-newer (the refresh is allowed)
```
That contradicts the file's own header in writing: "That derivation is a
LOWER bound … so MORE comments count as newer, never fewer — the check
may ask for an acknowledgement it did not strictly need, and cannot skip
one it did." Here it skips one it did. Still reachable after this PR,
because a body can be written by hand or predate the tool. Dedupe words:
`lastWriteStamp`, `unreadComments`, `none-newer`, `stampSpan` rollover,
unread knock.
**Noted, not filed.** The two refusal footers (`refusalText`,
`unrecognisedOpenerText`) still spell the quoted token as
`{{WAS:YYYY-MM-DDThh:mmZ}}`, minute-grained. Left alone deliberately:
those strings *prescribe* a spelling to write, and the minute form is
the canonical one, whereas the header and usage text corrected here
*describe* what the tool accepts — a description that was narrower than
the contract is wrong in a way a prescription is not. Whoever next edits
those footers is the one who would carry it.
## Landing
Non-governed (`scripts/pm/**`). Left DRAFT, no reviewers requested, no
auto-merge armed, no ccr route touched — the seat lands it in-seat after
review.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0865f41 commit 3964efb
1 file changed
Lines changed: 191 additions & 19 deletions
0 commit comments