Skip to content

fix(scripts): refuse to record the sdui lockstep from a tree the pin does not name - #17268

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-16797-sdui-lockstep-pin-guard
Sep 10, 2026
Merged

baozhoutao merged 2 commits into
mainfrom
claude/issue-16797-sdui-lockstep-pin-guard

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #16797

gen:sdui-lockstep read the record's content from whatever the objectui checkout had at HEAD, while stamping recordedAgainstPin from .objectui-sha. Nothing joined those two, so a checkout not sitting on the pin wrote a record that named the pin while describing a different tree — silently, at exit 0.

judge()'s pin-moved clause cannot reach that, which is the whole point: it compares record.recordedAgainstPin against the live pin — the stamped side against the pin file, two values that came from the same file and therefore agree by construction. The record was self-certifying, and the one check comparing anything compared the stamp. The join has to happen in the generator, the only moment both the tree and the pin are in hand.

Route taken: 2 (refuse loudly), and why

The card left the direction open between (1) resolving the objectui checkout to the pin before reading and (2) refusing when HEAD != .objectui-sha. This PR takes route 2. Three reasons, in the order they decided it:

  1. It is the evidenced one. Comment 5580159689 records that a pre-generator equality assertion was already field-proven against this exact seam during chore(console): bump the objectui pin to 53ded82bf7a4 — the shipped console lowers array analytics filters #16788's second regeneration — run by hand, and it is what made a correct record safe rather than lucky. This PR lifts that assertion into the generator so it protects every future bump instead of depending on the operator remembering. That the same operator, holding this defect in mind, still had to catch it a second time within the hour is this card's own strongest argument that a convention is not enough.
  2. It needs no new machinery, and route 1 does. Reading from the pin's tree means git writes inside somebody else's checkout — a larger permission surface, and its behaviour over uncommitted work there would have to be defined before it could be trusted. Route 2 adds one comparison.
  3. It matches this file's stated posture. The header's "Absence is loud, everywhere" already asserts every input before any verdict; --update now asserts the same way before any record.

⛔ The forbidden repair (stamping HEAD into recordedAgainstPin) is not taken and the field is unchanged: it would make the field honest and useless, and it would make judge()'s comparison pass forever, destroying a working check along with the record.

What changed

One file, scripts/check-sdui-lockstep.mjs (+192 / -6), across two commits:

  • judgeRecordability({ head, livePin }) — a new exported pure function, so --self-test can pin both directions offline. Empty array means recordable; otherwise [head-off-pin], [pin-unusable] or [head-unusable], in judge()'s finding shape so both are read the same way. Absence and malformation are distinguished from being off the pin: a pin that cannot be compared must not read as a checkout on the wrong commit.
  • update() consults it first, before anything is read or written — ahead of the dirty-tree, delimiter and code-set refusals, because on the wrong revision every later reading is a reading of the wrong tree and a complaint about it is noise. The refusal names both revisions and both remedies (park the checkout on the pin, or move the pin first).
  • The pin read moved up and became loud. --update used to read .objectui-sha inline at the stamp with no handling; an unreadable pin threw a raw stack. It is now read once, with the same refusal shape main() already uses, and recordedAgainstPin is stamped from that same value — so what is CHECKED and what is STAMPED cannot drift apart again.
  • A new self-test battery (7 cases) plus 3 cases in the wiring battery; the roster floor moves 5 to 6 with it, so deleting the battery cannot silence it.

Acceptance — both legs, measured at 3d38cc7883

Against a real objectui clone. Pin is 53ded82bf7a4; today's objectui tip is 4fa0eb9c4ad2.

⭐ Leg 2, the REVERSE control — objectui parked ON the pin. Still produces output, and the output is byte-identical:

objectui HEAD=53ded82bf7a494f54e344e19099dbf00854b8694
EXIT=0
  recorded objectui@53ded82bf7a4 — 214 region line(s), blob 0131f27cf86d, 24 diagnostic code(s), against pin 53ded82bf7a4.
blob before=bc425df35b97c606708ce1a79e9b4642de94658f
blob after =bc425df35b97c606708ce1a79e9b4642de94658f   BYTE-IDENTICAL: YES
git diff HEAD: []

Leg 1, the firing leg — objectui parked on a non-pin commit. Refuses, and writes nothing:

objectui HEAD=4fa0eb9c4ad248cd43f6366e21e01ccab05bf42d
EXIT=1
  [head-off-pin] the objectui checkout is at 4fa0eb9c4ad2, and .objectui-sha names
    53ded82bf7a4. Recording now would write a record stamped with the pin while
    describing a different tree — ...
blob after refusal=bc425df35b97c606708ce1a79e9b4642de94658f   (unchanged)
git diff HEAD: []   git status: []

Positive control for leg 1 — the same corpus, the UNFIXED script. origin/main's version of the file, run against the same non-pin checkout, so the zero above is a reading and not a silence:

CONTROL_EXIT=0                            (silent, at exit 0)
  recorded objectui@4fa0eb9c4ad2 — 25 diagnostic code(s), against pin 53ded82bf7a4.
objectui.rev       = 4fa0eb9c4ad248cd43f6366e21e01ccab05bf42d
recordedAgainstPin = 53ded82bf7a494f54e344e19099dbf00854b8694
rev === pin ?      = false

That is the defect reproduced a third time, on a third independent objectui revision (the card measured d65b2baa, #16788's sync measured f76f4362, this is 4fa0eb9c), and it is the plain default state of a fresh git clone into the sibling path resolveObjectui looks at. Restored afterwards by git checkout HEAD -- ..., verified by state: blob back to bc425df3, git diff HEAD empty.

The parse.ts trap, re-measured on today's pair — so the legs above are not the leg the card warned against:

parse.ts  @pin=e162048c372b85bdd1d6f8ca8606ec95860d6152
parse.ts  @tip=e162048c372b85bdd1d6f8ca8606ec95860d6152   (byte-identical)
src TREE  @pin=7b91c2c969c9d0cd097766cae3eaf56f42fdb9f1
src TREE  @tip=0917b649f915d931a7b27658357700708d8d56d2   (differs)

The grammar-region blob is 0131f27cf86d in both the good and the bad record, which is exactly why a leg comparing parse.ts or the region alone would have seen nothing. The 25-vs-24 difference lives in the tree, and the legs above compare the whole generated record.

Ablation — the new cases can actually fail

Each mutation was proved on disk (anchor grep count before/after, blob hash vs the HEAD blob) and restored under a trap, verified by git diff HEAD being empty and the blob returning to 06cc0483:

mutation self-test
head !== livePin branch disabled EXIT=1 — names a checkout one commit off the pin is refused, not recorded
update() stops calling the judge EXIT=1 — names the generator CALLS the recordability judge, so that battery is not phantom
stamp reverts to a second read of the pin file EXIT=1 — names it stamps recordedAgainstPin from the value it compared

⚠️ The first run of this ablation found a real hole in this PR and it is worth recording: the phantom-check assertion originally scanned the whole file for judgeRecordability({ head: — which matched its own battery's test code, so deleting the generator's call left the self-test green at exit 0. The assertion was pinning itself. It is now scoped to update()'s own body slice, with the slice bounds asserted so a rename cannot make it vacuous, and the comment at the site names the measurement. Every "is it wired" assertion in a self-testing script has this shape; only the ablation shows it.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 32 families from the actual change set; all 32 run, all exit 0. Reconciled: dispatch-gates --ran reports 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN. pnpm check:sdui-lockstep (self-test plus gate) exits 0 at the final commit.

⚠️ One earlier reading was NOT MEASURED and is reported as such rather than as a pass: the first --self-test in the fresh worktree returned exit 3 — ts-parse: PREREQUISITE NOT MET, typescript not installed. It says nothing about the tree. Re-run after pnpm install and it is a real exit 0.

Lint — a declared narrowing, with its three readings. pnpm lint is a repo-wide scan CI owns; run here narrowed to the one changed file, with the narrowing proven rather than assumed:

  1. Population, read from eslint's own config (eslint --print-config, not guessed): exactly 2 rules are enabled for this path — no-restricted-imports and comment-swallow/no-code-inside-block-comment.
  2. Count, read from --format json: 1 file linted, 0 errors, 0 warnings, exit 0.
  3. Invariance: this repo runs one eslint.config.mjs and it never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and independently measured at eslint.config.mjs:327. A one-file diff therefore cannot move the verdict on any file it does not touch.

Positive control for that zero, same corpus and same invocation: a restricted import planted in a sibling scripts/*.mjs file gives exit 1, 1 error, rule no-restricted-imports. Removed; tree clean.

Changeset: graded skip-changeset, measured

Nothing publishable moves. Measured rather than asserted: of 24 workspace packages, 0 have a files[] that would ship the repo's scripts/ directory, and no package reaches it through bin or exports; the root package @objectstack/spec-monorepo is private: true. Positive control on the same scan: 23 of those 24 packages do ship a dist path, so the scan reads files[] and a zero from it is a reading.

验收备注

  • noted, not filed — with judgeRecordability in place, record.objectui.rev and record.recordedAgainstPin are now equal by construction at record time, so judge() could cheaply re-assert that equality offline and catch a self-certifying record produced by the old generator. Not filed and not done here: no such record exists (leg 2 proves the shipped record regenerates byte-for-byte at the pin), so there is no defect to reproduce — it is defence in depth, not class (a), (b) or (c). It also adds verification surface, which is what this card's reverse leg warns against. Whoever next bumps the pin is the party who touches this file; they hit nothing, because the generator now refuses first.
  • The default sibling path resolveObjectui falls back to (../objectui) puts a fresh clone at objectui tip, i.e. exactly the trap condition, with no step in between. That is now caught rather than recorded; noted because it explains why this fired twice in one hour rather than being exotic.

⛔ #16715 is not addressed here — adjacent gate, different failure.

A note on this branch's history

The second commit exists because the wiring fix the ablation forced was made after the first commit was already pushed. It was stacked rather than amended in: a force-push is not available to this seat, and rewriting a pushed branch is not worth hiding the fact that the ablation caught something. Every measurement in this body was re-run at the pushed head 3d38cc7883, whose tree is byte-identical to the tree those measurements were first taken against (git diff between them is empty): check:sdui-lockstep exit 0, dispatch-gates --ran still 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN, leg 2 byte-identical, leg 1 exit 1.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU


Generated by Claude Code

…does not name

`gen:sdui-lockstep` read the record's content from whatever the objectui
checkout had at HEAD while stamping `recordedAgainstPin` from `.objectui-sha`.
Nothing joined those two, so a checkout off the pin wrote a record that named
the pin while describing a different tree — silently, at exit 0.

`judge()`'s `pin-moved` clause cannot reach that: it compares the stamped side
against the pin file, two values from the same file that agree by construction.
The join has to happen in the generator, the only moment both the tree and the
pin are in hand.

`judgeRecordability` is that join, as a pure function so `--self-test` can pin
both directions: on the pin it records as before, off the pin it refuses and
names both revisions plus the two ways to move. `recordedAgainstPin` is now
stamped from the value the refusal compared, so what is checked and what is
stamped cannot drift apart again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026
… own body

The self-test's wiring cases scanned the WHOLE FILE for
`judgeRecordability({ head:`, which matched the battery's own test code. An
ablation proved the consequence: deleting the generator's call to the judge left
the self-test GREEN at exit 0 — the assertion was pinning itself, and the
battery it claimed to protect was a phantom check.

Scoped to a slice of `update()`'s own body, with the slice bounds asserted so a
rename cannot make the case vacuously green instead. The stamp case is scoped
the same way, for the same reason.

Also stops the off-pin case from crashing when the guard is ablated: reading
`[0].includes(...)` off an empty array threw a TypeError where a named failure
belongs, so the self-test redded for the right reason with the wrong words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@baozhoutao
baozhoutao marked this pull request as ready for review September 10, 2026 02:00
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 7ddf13d Sep 10, 2026
38 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16797-sdui-lockstep-pin-guard branch September 10, 2026 02:28
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

2 participants