Skip to content

Commit 3964efb

Browse files
claude[bot]claude
andauthored
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

File tree

0 commit comments

Comments
 (0)