Skip to content

fix(pm): post-stamped's unread-knock check never narrows on a stamp that names no instant - #18294

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-18293-post-stamped-unread-knock-honours-lower-bound
Sep 15, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-18293-post-stamped-unread-knock-honours-lower-bound

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #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 #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 6cd7b907)
  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 48275456)
  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

…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
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f78f2c061d5cd9525b5b259d443ceeb32f8f7a58

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T07:15Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18293, claim 5678555282, report 5678904168 (posted through the patched tool, act-clock token omitted because the report quotes impossible stamps — stated, accepted); Clause-②: no on the claim; default-tier build, seat review at tier.

① Derived judgments

  • Accept set / public surface: none moves — scripts/pm/post-stamped.mjs (+138/−24) against merge-base 3964efb2; nothing published; skip-changeset read back (size/m). Non-governed ⇒ in-seat landing.
  • The change: lastWriteStamp judges every stamp in the stored body with the calendar predicate PR fix(pm): post-stamped refuses a quoted stamp that names no real instant #18292 added (stampRealInstant) and never reads an unreal one as the last write — it falls back to the newest REAL stamp (earlier ⇒ the unread window only widens, the header's 「never fewer」 direction), and when none is left the existing no-stamp rule applies; the return is always an object that names the stamps it did not read, and the refusal / pass lines and the --json transcript say so. The seat took the pure-core reading on both trees with the measured scenario (newest stamp 2026-04-31T00:00Z, an older real 2026-04-29T09:00Z beside it, a knock at 2026-04-30T12:00:00Z): origin/mainlastWriteStamp = the rolled-over instant (2026-05-01), kind: none-newer, ok: true, and the same with --ack-through naming the knock (the ack path never reached) — the filed defect; the head → lastWriteStamp = the 2026-04-29 stamp with the unreal one named, kind: unacknowledged, ok: false, and acknowledged when the ack names the knock. The all-real control is unchanged on both trees. The seat's premise that the no-parse half (2026-13-45T99:99Z) also read none-newer is REFUTED by the dev and by the seat's own reading: on origin/main that half already returned a null last write and unacknowledged — right by accident through a different path, pinned by nothing; the head brings both halves under the one predicate and pins them (the mutation leg leaves exactly those two cases green, the honest signature). Self-test 186 / 10 on main → 204 / 10 on the head (the unread-knock battery's floor 23 → 41).
  • Gates 32 / 32 / 0 / 0 by --ran; the battery 1730 cases pass, 447.5 s detached, exit captured; --pair 18294 exit 0. Seat re-run on a detached worktree of the head: self-test 204, ESLint 0/0, check-self-test-wired OK, check-watch-hint-literal OK, 32 derived. Reverse verification by the dev: ablation from the committed state with blob-hash proofs (base 6cd7b907, head 48275456), plus a mutation leg on the guard alone (8 of 204 cases red, restored and hash-proved).
  • Design (the choice the dispatch left open): fall back to the newest real stamp rather than refuse-and-require-ack — argued on the four axes and accepted: refusing dead-ends a live path (a seat post with an unreal stamp and zero comments could never be refreshed again), one predicate keeps deciding the instant, the tightening sits at the derivation, no new flag or refusal kind; the stated cost (over-asking) is the header's chosen direction.

② Semver level

Not applicable — nothing published moves; skip-changeset measured.

③ Boundary flags

open_questions: none. Five declared deviations, each answered: (1) lastWriteStamp now always returns an object — accepted, the file is its only consumer (grep), the shape pinned. (2) one new --json key unreal_stamps — accepted, the machine transcript must say what the human line says. (3) the reading taken through the pure core, since --dry-run returns before the unread check by design — accepted, the seat took the same route. (4) the report posted with no act-clock token — accepted. (5) the GitHub copy of the report lacks the last finding and this deviation, measured by the act of posting — accepted, the write budget forbade a second comment; the seat files it from the hand-back. Out of scope, routed: the ack-not-newest refusal's after-count on same-second comments → #18295; the read-back verdict's false 「MUTATED」 on the platform's trailing-newline strip and footer append → #18296 (both serial behind this PR, same file); noted, not filed — the pass line stays one line; --dry-run never reaches the unread check (self-documented); the probe-read / judge-read window in main() (no reproducer). Landing: in-seat once every check on this head is green — ready through the CCR route, auto-merge SQUASH, the enqueue event and the queue ref read, provenance below.

Implemented-by: claude/issue-18293-post-stamped-unread-knock-honours-lower-bound
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-15T11:00Z. Non-governed landing (scripts/pm/**): review of record 5678972520 PASS on head f78f2c061d5cd9525b5b259d443ceeb32f8f7a58, ACCEPT on #18293; every check on that head read green at 2026-09-15T10:59Z (24 success / 11 skipped / running none / failed none). The seat flipped the PR ready through POST …/pulls/18294/ccr/ready_for_review and armed auto-merge SQUASH through PUT …/pulls/18294/ccr/auto_merge; the added_to_merge_queue event reads 2026-09-15T10:59Z; the queue ref refs/heads/gh-readonly-queue/main/pr-18294-* read 1 fifteen seconds after the enqueue. Landing is confirmed by the three readings (merged_at + git log origin/main carrying (#18294) single-parent + the queue ref gone) at the next wake; then #18293's residue is stripped, the landing record posted, and #18295 / #18296 (same file) become dispatchable.


Generated by Claude Code

Merged via the queue into main with commit 5a18f32 Sep 15, 2026
37 checks passed
@claude
claude Bot deleted the claude/issue-18293-post-stamped-unread-knock-honours-lower-bound branch September 15, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant