fix(pm): post-stamped counts a same-second later comment as after the acknowledged one - #18297
Conversation
… acknowledged one `unreadComments` picked `newest` with a `created_at` sort that tiebreaks on `id`, but derived `after` — the comments that landed after the acknowledged one — from a strictly-later comparison on `created_at` alone. Two comments written inside one second (a batch, a bot) with `--ack-through` naming the lower id: the refresh was rightly refused as `ack-not-newest` and the remedy line named the right id, but the refusal's head read "0 comment(s) landed after it" and listed nothing — a count contradicting the refusal it sits in. Both now read ONE comparator, `byCommentOrder` (`created_at`, then `id`), so the newest pick and the "landed after" set cannot disagree about which comment follows which. No refusal changes which route it fires on; only the count and the listing move. Self-test: the filed reproducer (after = [101], head 1, the listing names it), the newest/after agreement invariant, the ordinary later-second control unchanged, and the same-second pair with the ack naming the higher id still cleared. Unread-knock battery floor 41 → 49 (measured). 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 #18295
The defect
unreadCommentsinscripts/pm/post-stamped.mjspickednewestas the last element of a list sorted bycreated_atwith anidtiebreak, but derivedafter— the comments that landed after the acknowledged one — from a strictly-later comparison oncreated_atalone. Two comments written inside one second (a batch, a bot) with--ack-throughnaming the lower id: the refresh was rightly refused asack-not-newestand the remedy line named the right id, but the refusal's head read0 comment(s) landed after itand listed nothing — a count contradicting the refusal it sits in.Two orderings answering one question is the whole defect, so the fix is one ordering, not a second comparison bolted onto the filter.
The fix
One comparator,
byCommentOrder(created_at, thenid), declared once besidecommentCreatedMsand read by both the newest pick and the "landed after" set.idbreaks only an exact tie, so it never outranks the clock; comments whosecreated_atis unreadable share one instant and are ordered by id — the same tie the sort already resolved that way. The window filter keeps reading the instant alone, because it compares a comment against a stamp, not against another comment.Which refusal fires on which input is unchanged; only the count and the listing move.
Reverse verification — ablation from the committed state, hash-proved
Blobs: HEAD
0e6d55abf5a895c285bdb20879fb2024915543cb, pre-fix48275456ba4f55f10422aa25c76cba7e45015e73.Leg A — the unpatched derivation (pre-fix blob restored on disk; on-disk hash re-read and matched,
byCommentOrderoccurrences 0). The reproducer, called on the pure core:Restored with
git checkout HEAD --; on-disk hash back to0e6d55ab,git diff HEADandgit status --porcelainboth empty. After the restore:Leg A says nothing about the new self-test cases — a whole-file revert takes them with it, so its
--self-testis green at 204 cases. Recorded as observed, not as a pass.Leg B — the derivation alone reverted, the new cases kept (mutation on top of HEAD; mutated blob
a577b24bb6afa543d4db105f44cc8c9121e4e956, proved different from the HEAD blob and proved on disk by occurrence counts: new form 0, old form 1).--self-testexits 1,4 of 212 case(s) failed:The fifth new refusal case stays green there on purpose: the refusal itself was always right, only its count was not. Restore: on-disk hash back to
0e6d55ab,git diff HEADandgit status --porcelainempty,--self-testback to 212 passing. Both legs run atrap … EXIT INT TERMrestore against an absolute path; neither leaves a temporary file in the tree.Self-test
204 → 212 cases across 10 batteries; the unread-knock battery's floor 41 → 49, the measured count. Added: the filed reproducer (after =
[101], the head reads 1, the listing names 101), the newest/after agreement invariant, the ordinary later-second control restated as unchanged, the same-second pair with the ack naming the higher id still cleared, and the ordinary ack-the-newest case still cleared.Gates
node scripts/pm/dispatch-gates.mjs --commands scripts/pm/post-stamped.mjsderived 32 commands; all 32 ran, all exit 0. Reconciliation on the same tree:Among them
pnpm check:pm-dispatch-gates(the gate source's own battery, run detached: 1730 cases, exit 0, 439.0s on this box),pnpm check:pm-post-stamped,pnpm check:nul-bytes,node scripts/check-self-test-wired.mjs. Plus, outside the derivation:node --stack-size=4000 node_modules/eslint/bin/eslint.js scripts/pm/post-stamped.mjs— clean; and a control-character self-scan over the file — no hit.Acceptance notes
skip-changeset:scripts/pm/**is tooling, shipped by no package'sfiles[]— nothing published moves.readBackVerdictcompares stored to sent byte-for-byte, so the platform's trailing-newline strip (and the footer append on comments) reads as 「the platform MUTATED the body」 on nearly every write — the one warning meant for the sanitizer is now noise #18296 names a different defect shape in this file and is not addressed here.commentCreatedMshas exactly three call sites, and the third (the read-window filter) compares a comment against a stamp instant rather than against another comment, so it is not a second ordering.🤖 Generated with Claude Code
https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
Generated by Claude Code