Repository navigation
fix(pm): post-stamped refuses a quoted stamp that names no real instant - #18292
claude[bot] merged 2 commits into
Conversation
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: the value was rendered VERBATIM and `--dry-run`
reported "1 quoted stamp(s) rendered verbatim" at exit 0. A stamp no clock could
ever have shown went onto the board as a reading.
`Date.parse` has two ways of not meaning what the digits say. An out-of-range
field gives NaN. A field inside its range but not on the calendar -- 31 April,
29 February in a non-leap year, hour 24 -- is rolled FORWARD into the next real
date and returned as an ordinary number, so `2026-04-31T00:00Z` parses, sits in
the past, and is not the date its digits spell. Measured on this runtime: that
second half was accepted and rendered today.
One rule refuses both. `stampRealInstant` re-renders the parsed instant at the
stamp's own grain and requires the bytes back; a value that round-trips to a
different date is not a reading of the instant it names. It sits in
`stampRefusals` beside the direction rule, in the same scan, so exactly one rule
still decides what a quoted stamp is -- and the three fire one at a time: shape,
then calendar, then direction.
The positional and mixed remedies hand the seat `{{WAS:<the typed stamp>}}` with
the stamp filled in, so the new closure would have made them prescribe a refusal
for an impossible stamp. Both now read one predicate, `quotedRouteClosed`, which
shuts the offer off for either reason instead of the future alone.
Header and usage prose corrected in the same edit: the accepted shape has always
taken the seconds grain as well as the minute, and both said minute only.
Self-test: 152 cases across 9 batteries -> 186 across 10; the new battery is
floored at 34 and SELF_TEST_BATTERY_FLOOR raised 9 -> 10.
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Co-authored-by: Claude <noreply@anthropic.com>
…st-stamped-refuses-impossible-instants
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing provenance — skills seat, session Generated by Claude Code |
…hat names no instant (objectstack-ai#18294) Fixes objectstack-ai#18293 The `--body` refresh's unread-knock check derives "the body's last write" from the newest protocol stamp in the stored body, and the file's own header promises that derivation is a LOWER bound: "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". It was not a lower bound. `lastWriteStamp` read the newest stamp through `Date.parse` alone, which rolls an impossible date FORWARD and hands back an ordinary number, so a stored `2026-04-31T00:00Z` read as `2026-05-01T00:00:00.000Z` — later than its own digits — and a knock at `2026-04-30T12:00:00Z` fell outside the window. The check answered `none-newer`, the refresh proceeded, and the knock was voided with no signal to the knocker. PR objectstack-ai#18292 refuses such a stamp on the WRITE side. A stored body can still carry one: hand-written, edited through the platform's own editor, or written before this tool existed. ## What changed One rule decides what the last write instant is. `lastWriteStamp` now judges every stamp with `stampRealInstant` — the same round trip the write side refuses a quoted stamp with, one predicate and never two — and a stamp that names no instant the calendar has is not read as the last write at all. The derivation falls back to the newest REAL stamp in the body, which is earlier, so the window only widens; a body whose stamps are all unreal lands on the existing no-stamp rule and counts every comment. `lastWriteStamp` always answers an object now: `stamp`/`from` are null when nothing readable is there, and `unreal` carries the stamps it refused with the date each one rolls over to (null for the half that does not parse at all). The refusal, the transcript pass line and the `--json` transcript name those stamps, so a window wider than the body looks is something the transcript can say, instead of claiming the body carries no stamp at all. Nothing else moves. A card with nothing newer is still written, so the acceptance control the header states — "no comment newer than the last write ⇒ no flag, no refusal, and the write is what it was before this check existed" — holds unchanged. ## Why this fallback and not "refuse and require --ack-through" The card offered two honest fallbacks: treat the last write instant as UNKNOWN and refuse the refresh until `--ack-through` names the newest comment, or fall back to the newest real stamp. The header's own rule picks the second. - Refusing outright is not "the window never narrows", it is "the refresh never happens". On a card with an impossible stamp in the body and ZERO comments there is no newest comment to name, so `--ack-through` has no legal value and the seat post can never be refreshed again — the one dead end this tool must not build. That also breaks the acceptance control quoted above, which the header states as a promise. - Falling back is strictly the header's rule applied one stamp further: the derived instant can only move EARLIER, so the unread set can only grow. Where the refusing option would have acted at all (there are comments), this option acts identically — every comment counts as newer, and `--ack-through` naming the newest clears it. It is the same protection without the dead end. - It is also one rule instead of two. Refusing adds a second notion of "last write instant" (unknown, beside absent) and a second refusal route; the file already warns, at `quotedRouteClosed`, that two spellings of one question is how a remedy comes to prescribe a refusal. This is a contract tightening at the derivation, not a tolerant fallback at a consumer: the unreal stamp is refused as a reading of anything (the write side already refuses to write one), so no caller can obtain a narrower window by storing a bogus date. ## Reverse verification (ablation from the committed state, hash-proved) Base blob `6cd7b9074ac019c85d16e140b1a335bee6fe5b54` restored over `scripts/pm/post-stamped.mjs`, reading taken, HEAD blob `48275456ba4f55f10422aa25c76cba7e45015e73` restored, `git diff HEAD` empty. The on-disk hash was compared to the expected blob on both legs before any reading was taken. Measured scenario — stored body whose newest stamp is `2026-04-31T00:00Z`, one knock at `2026-04-30T12:00:00Z`: ``` BEFORE (base blob on disk, hash 6cd7b90) lastWriteStamp = {"stamp":"2026-04-31T00:00Z","from":1777593600000} -> 2026-05-01T00:00:00.000Z unreadComments = kind=none-newer ok=true newer=0 [the knock is voided] AFTER (HEAD blob on disk, hash 4827545) lastWriteStamp = {"stamp":null,"from":null,"unreal":[{"stamp":"2026-04-31T00:00Z","rolledTo":"2026-05-01T00:00Z"}]} unreadComments = kind=unacknowledged ok=false newer=1 [the knock is refused, not voided] ``` Same body plus an older real stamp `2026-04-29T09:00Z` beside the impossible one: before, `since` was the rolled `2026-04-31T00:00Z` and the reading was `none-newer`; after, `since` is `2026-04-29T09:00Z` and the reading is `unacknowledged` — the window measured from earlier, which is the "never fewer" direction. Control legs: a card with no comments is still written (`none-newer`, both before and after), and `--ack-through` naming the newest comment clears (`acknowledged` after; the base tool never reached that path because it had already answered `none-newer`). A second leg mutates only the guard on the committed tree (the unreal stamp recorded but still read as the last write) and leaves the new cases in place: `--self-test` goes RED, 8 of 204 cases failing, then restores to the HEAD blob with `git diff HEAD` empty and 204/204 green. Two of the added cases stay green under that mutation, on purpose — see the acceptance note below. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 32 families for this change set; all 32 ran and all 32 exited 0. `node scripts/pm/dispatch-gates.mjs --ran` verdict: "32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero — all 32 recorded an exit code and none of them is 3)". The gate-source battery `pnpm check:pm-dispatch-gates` ran detached: exit 0, 447.5s on this box, 1730 self-test cases. Plus the tool's own `--self-test` (186 cases before, 204 after; the unread-knock battery floor 23 raised to 41), ESLint on the file (exit 0, no findings), `node scripts/check-self-test-wired.mjs` (exit 0), and a control-character self-scan over the file (no hits). No changeset: nothing published moves. `scripts/pm/**` is not on any package's `files[]`. ## Acceptance notes - The card asked that a stamp which does not parse at all (`2026-13-45T99:99Z`) reach the same outcome as the rollover. Measured on the base tool, it ALREADY did — `stampSpan` returns null for it and the old loop skipped it — so that half was correct by accident, through a different code path, and unpinned. It is pinned now and comes from the same predicate as the rollover half. The mutation leg leaves those two cases green for exactly that reason. - Noted, not filed — `unreadPassText` grew a clause and stays a single line, so "the one line a transcript carries" still holds. Successor: the next author of this file. - To file, class (a), reproducible: `unreadComments` computes `after` for the `ack-not-newest` refusal with a strictly-later comparison on `created_at` only, while the newest is chosen with an id tiebreak. Two comments sharing one `created_at`, acknowledging the lower id, refuses with a head reading "0 comment(s) landed after it" and lists nothing — a count that contradicts its own refusal. The remedy line still names the right id, so nothing is lost; the text is simply false. Reproducer: `unreadComments({ storedBody: 'seat 2026-09-12T10:00Z', comments: [{id:100,created_at:'2026-09-12T11:00:00Z'},{id:101,created_at:'2026-09-12T11:00:00Z'}], ackThrough: 100 })`. Dedupe words: post-stamped, ack-not-newest, after, created_at tiebreak, same-second comments. Pre-existing, untouched by this PR. Draft on purpose: `scripts/pm/**` is not a governed surface, and the dispatching seat lands this in-seat after review. No reviewers requested, no auto-merge armed. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: claude[bot] <noreply@anthropic.com>
Fixes #18289
The defect
The quoted-stamp shape rule is the protocol's stamp regex, and a regex counts digits:
2026-13-45T99:99Zsatisfies every class in it. The direction rule then let it through in the quiet direction —stampSpanasksDate.parse, which answers NaN, so the span is null, so "is this later than now" isfalse. Not future, therefore not refused.Measured on
origin/mainat499f7f9e, verbatim: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
stampSpanparses and whether normalisation counts as real. Measured on this runtime,Date.parsehas two ways of not meaning what the digits say, and only the first is loud:Date.parse2026-13-45T99:99Z,2026-01-01T99:00Z,…T00:99Z,2026-00-01…,…T00:00:60Z2026-04-31T00:00Z2027-02-29T00:00Zquoted-in-the-future— the wrong reason, and only because 2027 happens to be ahead of the clock2026-09-10T24:00ZSo the rolled-over half is live today and is not covered by a NaN check:
2026-04-31T00:00Zparses, 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.mjsonlystampRealInstant— 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;rolledTois null for the NaN half and never the input, so a caller cannot report a rollover that did not happen.quoted-no-such-instant, instampRefusalsbeside 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 existingpost-stamped: REFUSEDrow form with the offending span, on--dry-runand on a live write alike, at the sameEXIT_REFUSEDas the other refusals (the live path refuses inrenderBody, before any request — measured: exit 2, empty stdout, no network reached).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.--helpusage said the quoted payload was minute-grained; the accepted shape has always takenYYYY-MM-DDThh:mm:ssZtoo. 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.parsereturns unchanged.Reverse verification
Ablation from the committed state, absolute paths,
trap … EXIT INT TERM, restore proved by hash and by an emptygit diff HEAD— never by an exit code. There is no build step:node scripts/pm/post-stamped.mjsreads the source file, so the on-disk hash is what ran.{{WAS:2026-13-45T99:99Z}}[quoted-no-such-instant], stdout empty{{WAS:2026-04-31T00:00Z}}[quoted-no-such-instant]naming the rollover to2026-05-01T00:00Z--self-testPredicted 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_FLOORraised 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-29is pinned real by the calendar rule directly (stampRealInstant(…).real === true, andquoted-no-such-instantis 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
6fbb74e8after mergingorigin/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.mjsderived 32 families; all 32 run, all exit 0.node scripts/pm/dispatch-gates.mjs --ran …(written ascommand :: 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 withtail --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.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'sfiles[].Acceptance notes
To file (out of scope here — a different rule, a different scan). The same
stampSpanrollover 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 makeslastWriteStampreturn an instant later than the text names, so the refresh window starts late and a knock inside the gap is classifiednone-newer— the refresh proceeds and the knock is voided. Measured: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,stampSpanrollover, 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