fix(pm): post-stamped's unread-knock check never narrows on a stamp that names no instant - #18294
Conversation
…hat names no instant
The `--body` refresh derives "the body's last write" from the newest protocol
stamp in the stored body, and the file's header promises that derivation is a
LOWER bound: more comments count as newer, never fewer. It was not. The newest
stamp went through `Date.parse` alone, which rolls an impossible date FORWARD
and returns an ordinary number — a stored `2026-04-31T00:00Z` read back as
1 May, so a knock at `2026-04-30T12:00:00Z` fell outside the window, the check
answered `none-newer`, and the refresh voided the knock.
`lastWriteStamp` now judges every stamp with `stampRealInstant`, the same round
trip the write side refuses a quoted stamp with — one predicate, never two. A
stamp that names no instant (NaN or rolled over) is not read as the last write
at all; the derivation falls back to the newest REAL stamp, which is earlier, so
the window only widens, and a body whose stamps are all unreal lands on the
existing no-stamp rule and counts every comment. Nothing else moves: a card with
nothing newer is still written, so the acceptance control the header states
("no unread knock ⇒ the refresh is not affected") holds unchanged.
The derivation always answers an object now, carrying the stamps it refused to
read, so the refusal, the transcript line and the `--json` transcript can name a
window that is wider than the body looks instead of claiming the body carries no
stamp at all.
Self-test: 186 → 204 cases; the unread-knock battery floor 23 → 41.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
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 |
Fixes #18293
The
--bodyrefresh'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.lastWriteStampread the newest stamp throughDate.parsealone, which rolls an impossible date FORWARD and hands back an ordinary number, so a stored2026-04-31T00:00Zread as2026-05-01T00:00:00.000Z— later than its own digits — and a knock at2026-04-30T12:00:00Zfell outside the window. The check answerednone-newer, the refresh proceeded, and the knock was voided with no signal to the knocker.PR #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.
lastWriteStampnow judges every stamp withstampRealInstant— 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.lastWriteStampalways answers an object now:stamp/fromare null when nothing readable is there, andunrealcarries 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--jsontranscript 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-throughnames the newest comment, or fall back to the newest real stamp. The header's own rule picks the second.--ack-throughhas 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.--ack-throughnaming the newest clears it. It is the same protection without the dead end.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
6cd7b9074ac019c85d16e140b1a335bee6fe5b54restored overscripts/pm/post-stamped.mjs, reading taken, HEAD blob48275456ba4f55f10422aa25c76cba7e45015e73restored,git diff HEADempty. 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 at2026-04-30T12:00:00Z:Same body plus an older real stamp
2026-04-29T09:00Zbeside the impossible one: before,sincewas the rolled2026-04-31T00:00Zand the reading wasnone-newer; after,sinceis2026-04-29T09:00Zand the reading isunacknowledged— 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-throughnaming the newest comment clears (acknowledgedafter; the base tool never reached that path because it had already answerednone-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-testgoes RED, 8 of 204 cases failing, then restores to the HEAD blob withgit diff HEADempty 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 --commandsderived 32 families for this change set; all 32 ran and all 32 exited 0.node scripts/pm/dispatch-gates.mjs --ranverdict: "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 batterypnpm check:pm-dispatch-gatesran 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'sfiles[].Acceptance notes
2026-13-45T99:99Z) reach the same outcome as the rollover. Measured on the base tool, it ALREADY did —stampSpanreturns 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.unreadPassTextgrew a clause and stays a single line, so "the one line a transcript carries" still holds. Successor: the next author of this file.unreadCommentscomputesafterfor theack-not-newestrefusal with a strictly-later comparison oncreated_atonly, while the newest is chosen with an id tiebreak. Two comments sharing onecreated_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