Skip to content

fix(pm): post-stamped refuses a quoted stamp that names no real instant - #18292

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-18289-post-stamped-refuses-impossible-instants
Sep 15, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-18289-post-stamped-refuses-impossible-instants

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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 objectstack-ai/objectstack#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 26e7f6cef7675bfe9134e61255eefcf4a3bc6aca   HEAD blob 6cd7b9074ac019c85d16e140b1a335bee6fe5b54
ablated:  git hash-object = 26e7f6ce…  (= BASE)   grep -c stampRealInstant = 0   grep -c quoted-no-such-instant = 0
restored: git hash-object = 6cd7b907…  (= 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

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

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6fbb74e8bcb1197d17cae9688fcb0513bbde2bc5

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 #18289, claim 5677623467, report 5678039238 (posted through the patched tool with no act-clock token — the report quotes impossible stamps as evidence and the mixed rule would have read them as typed clocks; 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 (+191/−19) against merge-base bda3a60c (the branch merged origin/main when PR pm-dispatch: three measured readings — the statesTheSet census, the enqueue instant, and the battery's cap-sized runtime #18287 landed; the three-dot diff is one file); nothing published; skip-changeset read back (size/m). Non-governed ⇒ in-seat landing.
  • The change: a calendar rule inside stampRefusals, beside the direction rule, in the one scan — a quoted stamp in the digit shape must round-trip through stampSpan to the bytes it spells; it refuses BOTH halves of the defect: the NaN half the card named (2026-13-45T99:99Z) and the rollover half the dispatch left to measurement (2026-04-31T00:00Z → 2026-05-01, an ordinary finite number in the past that no earlier rule could see). The seat reproduced both on the same fixtures: the tool on origin/main prints its DRY RUN line with 「1 quoted stamp(s) rendered verbatim」 for each; the head REFUSES each with the [quoted-no-such-instant] row naming the rollover date. Controls seat-run on the head: a real minute-grain stamp, a real seconds-grain stamp and the past leap day 2024-02-29 render; 2027-02-29 is refused on both trees (direction rule on main, calendar rule on the head). The header and usage prose now state both grains; the two refusal footers keep the canonical minute spelling (prescription, not description — accepted). Self-test 152 / 9 batteries on main → 186 / 10 on the head (the new battery floored at its measured 34, the battery floor 9 → 10).
  • Gates 32 / 32 / 0 / 0 by --ran, run twice — on the pre-merge commit and again on the merge commit 6fbb74e8 after origin/main moved (the STALE TREE warning noticed, merged, never rebased); the battery 1730 cases pass twice (442.3 s, 437.6 s), exits captured to a file by the detached shell. Seat re-run on a detached worktree of the head: self-test 186, 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 on both legs, git diff HEAD and git status --porcelain empty after the restore, both halves RED-then-refused as predicted.

② Semver level

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

③ Boundary flags

open_questions: none. Five declared deviations, each answered: (1) the positional and mixed refusal TEXTS no longer prescribe a quoted token the tool now refuses — accepted, a refusal prescribing a refused remedy is the file's own stated anti-pattern; refusal kinds unchanged, pinned. (2) the 2028 leap-day control pinned at the rule level, the past leap day end-to-end — accepted, the future one is a direction refusal against the battery clock. (3) origin/main merged mid-run and the union re-run — accepted. (4) the footer strings keep the minute spelling — accepted as above. (5) no act-clock token in the report — accepted, the tool cannot classify content. Out of scope, routed: the READ side's lastWriteStamp rolls a stored impossible stamp forward and voids a knock in the gap — the header's 「lower bound, never fewer」 promise broken; filed as #18293 (serial behind this PR, reuses its predicate). Register candidates recorded, not filed: on the comment surface the platform's footer normalisation can be byte-non-identical at EQUAL length (this report's read-back) — rides #18280's PR-body/comment footer cells at that dispatch. 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-18289-post-stamped-refuses-impossible-instants
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-15T09:59Z. Non-governed landing (scripts/pm/**): review of record 5678109551 PASS on head 6fbb74e8bcb1197d17cae9688fcb0513bbde2bc5, ACCEPT on #18289; every check on that head read green at 2026-09-15T09:58Z (24 success / 11 skipped / running none / failed none). The seat flipped the PR ready through POST …/pulls/18292/ccr/ready_for_review and armed auto-merge SQUASH through PUT …/pulls/18292/ccr/auto_merge; the added_to_merge_queue event reads 2026-09-15T09:58Z; the queue ref refs/heads/gh-readonly-queue/main/pr-18292-* read 0 twelve seconds after the enqueue. Landing is confirmed by the three readings (merged_at + git log origin/main carrying (#18292) single-parent + the queue ref gone) at the next wake; then #18289's residue is stripped, the landing record posted, and #18293 (same file) becomes dispatchable.


Generated by Claude Code

Merged via the queue into main with commit 3964efb Sep 15, 2026
37 checks passed
@claude
claude Bot deleted the claude/issue-18289-post-stamped-refuses-impossible-instants branch September 15, 2026 10:20
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
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