Repository navigation
check:console-injection --require-stamp still passes on a stamp with an empty packages array #10595
Description
Activity
{ "issue": 10595, "status": "done", "branch": "claude/issue-10595-console-injection-empty-stamp", "pr": "https://github.com/objectstack-ai/objectstack/pull/10621", "premise_still_valid": true, "summary": "Re-verified the filing seat's reachability constraint on current main (bde0ab95de) and it HOLDS: writeStamp has exactly one call site outside its definition (assert-console-spec-injection.mjs:119), with a hard-coded single-element array, reached from both stamping paths (stamp(false) no-skew, stamp(true) success); every other exit is a failure exit that never stamps. No stamp this repo produces can be empty today. But the brief's constraint 2 ('whether it should refuse is a judgement call existing evidence does not pin') is where I'd push back, and the measurement is why. The filing seat framed the empty array as 'a stamp that asserts nothing'; measured, it is worse than that. All THREE of the gate's substantive verdicts (published-only detector present in bundle / stamp's own fresh witness missing / probe expiry) are derived per package entry inside the loop over that array, so an empty array silences every one of them: a dist literally carrying the PUBLISHED spec -- the #8134 defect this gate exists to end -- exited 0 under --require-stamp. That makes it strictly MORE vacuous than the state #10597 refused one input over (an unbuilt spec skips only the expiry re-check and leaves the two bundle assertions standing, as that PR's own header states). Refusing the lesser vacuity while tolerating the greater one is incoherent, so this follows the #10597 precedent a fortiori rather than as a taste call. Shipped BOTH layers, and they are not redundant: PRODUCER (writeStamp refuses an empty entries array) makes the state unrepresentable at the one chokepoint this repo controls, guarding the #9659 future where entries become derived rather than literal -- the caller's existing try/catch downgrades the throw to a warning and writes no stamp, converting it into the missing-stamp state the gate already refuses. CONSUMER (the gate refuses it under --require-stamp) is still required because its input crosses a CACHE BOUNDARY the producer guard cannot reach: the dist is restored from an entry written by another run and may be partially restored or 'modified after it was proved' (the gate's own existing failure text). Producer and consumer are deliberately not in one trust domain -- that is why this gate exists as a separate script at all. So the brief's ⭐ preference is followed, and the reason it is insufficient here is argued rather than skipped. Refusal is keyed to packages.length === 0, NOT the asserted === 0 notice, because a no-skew entry also leaves asserted at 0 but is a positive record the build looked and found nothing to tell the specs apart; keying on asserted would have silently failed every no-skew run #10597 kept passing. Exit 1 not 2, advisory when bare, matching the no-dist and no-stamp verdicts and #10597's explicit exit-code reasoning.", "tests": "All at final commit b322cfbf17 (gate union re-derived and run AFTER the last commit; nothing pushed since). REJECT SIDE ASSERTED POSITIVELY, before-vs-after against origin/main bde0ab95de, both under --require-stamp: empty stamp + benign bundle 0 -> 1; empty stamp + bundle carrying the PUBLISHED spec 0 -> 1 (the decisive row -- the gate's primary defect detector was silent); legit stamp + published-spec bundle 1 -> 1 unmoved. PRECISION CONTROLS, #10597's four-row table (spec not built / built unchanged / built moved forward / built caught up) x both flag modes, before/after: 0/0|1/1, 0/0|0/0, 0/0|0/0, 1/1|1/1 -- ALL FOUR UNMOVED, rows 2-4 as the brief required and row 1 (the one #10597 added) too. Also unmoved: no-skew stamp, no-skew + unbuilt spec, legit stamp all-good, no stamp at all. Exactly two cells in an 11-fixture x 4-mode matrix changed, both intended. ABLATIONS -- these scripts are executed from SOURCE by node (package.json: 'node scripts/check-console-injection.mjs'), no dist/build sits between edit and run, so no rebuild leg applies and I am not claiming one. (A) consumer refusal neutered (=== 0 -> === -1): anchor counts on disk went real 1->0, mutant 0->1; self-test RED with 4 failures including 'a PUBLISHED-spec bundle under an empty stamp no longer passes --require-stamp: expected 1, got 0'. Restored: anchors 1/0, cmp -s vs pre-ablation copy = byte-identical, self-test green. (B) producer guard neutered: anchors real 1->0, mutant 0->1; self-test RED with 'writeStamp([]) must throw ProbeError, got no throw' and 'writeStamp([]) writes no stamp at all: expected false, got true'. Restored: anchors 1/0, cmp -s byte-identical, green. Mutation confirmed by anchor grep -c in BOTH directions each time, never by an editor exit code. Self-test 27 -> 36 assertions (baseline counted by running origin/main's own sources from a scratchpad copy). Self-test case 12, the round trip that spawns the real assert-console-spec-injection.mjs, still passes -- the integration control proving the producer guard does not break the real producer. GATES: derived with 'node scripts/pm/dispatch-gates.mjs' with NO hand-supplied paths (it took the change set from the merge base itself: 2 paths, three-dot semantics). It named THREE families; the brief had named only the console one. All green at b322cfbf17: pnpm check:console-injection -> '✓ check-console-injection --self-test: 36 assertions over real fixture trees (real evaluate() path)'; pnpm check:cross-package-test-inputs -> 'All 60 self-test cases passed.' + 'OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; node scripts/check-cross-package-test-inputs.mjs -> same OK line. check:nul-bytes -> 'OK (scanned 6203 text file(s) -- 6203 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes)' plus a manual grep -naP control-byte sweep of both changed files (clean). ESLint on both changed files: exit 0, no output. Heavy work routed through the shared lock; quoting its own verdict line: 'os-verify-lock: VERDICT command-exit 0 · held the lock 2s · waited 114s (1m54s)'. Real gate end-to-end in this checkout prints the pre-existing no-dist policy unmoved (bare exit 0 / --require-stamp exit 1) since packages/console/dist is absent here.", "open_questions": [], "out_of_scope_findings": [ "filed as #10622: repo has no formatter of record (no prettier/editorconfig/format script, none in workflows, AGENTS.md silent) and Prettier's DEFAULTS reject three untouched main files -- `prettier --write` on a changed file would bury the diff under a whole-file quote-style rewrite; labelled finding, unassigned, no pm:queue" ] }
Generated by Claude Code
⚠️ This claim comment is LATE and that is a defect on the PM seat, recorded rather than quietly backfilled. The assignee andpm:dispatchedwere set at dispatch time, butCLAUDE.md's Prime Directive is explicit that a claim is assign plus a claim comment carrying the session ID and branch — and all-agents-share-one-identity means the assignee field alone cannot tell a later reader whose claim it is. Caught by the dev seat working #10595, which read the comments as instructed, found none, and said so instead of assuming. No competing claim existed on any of these, so no work was duplicated; the exposure was real regardless.Claim:
/pm-dispatchdomain:devx execution seat (bug-first round).
Session:session_01DdCnBGcHeufjrq7drTD3wt
Branch:claude/issue-10595-console-injection-empty-stamp
Worktree:objectstack-issue-10595
Domain:domain:devx
File surface:scripts/check-console-injection.mjs+scripts/console-spec-probes.mjs⛔ Governed surface out of scope for the diff (
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md,content/docs/releases/**) and the #9465 epic fence (.changeset/tooling,cut-rc.yml,release.yml, rootpackage.json).
⭐ Changeset:AGENTS.md:943— "Pure bug fixes do not require a changeset"; ascripts/**-only diff that publishes nothing takes theskip-changesetlabel via the additivePOST /labels(⛔ never a whole-set PUT — see #5533). Precedent: PR #10502.
Generated by Claude Code
Observation found while implementing #10428 (fixing the spec-blob vacuity path). Filed rather than fixed there: it is a different input — the stamp's shape, not the tree's spec — and whether it should fail is a judgement call rather than something existing evidence pins, so it did not qualify for an in-scope fix.
What I measured
readStamp()inscripts/console-spec-probes.mjsshape-checksparsed.packageswithArray.isArray(...), which accepts[]. A dist stamped{"stampVersion": 1, "packages": []}then reaches the end ofevaluate()withasserted === 0, printsand exits 0 — including under
--require-stamp.That is structurally the same shape #10428 closed one input over: a run that satisfies
--require-stampwhile making no assertion at all about the artifact it is guarding.--require-stampcurrently proves a stamp exists and is well-formed, not that it says anything.Why it is not urgent
Not reachable from the real producer.
scripts/assert-console-spec-injection.mjscallswriteStamp(distDir, [...])at exactly one site (line 119) with a single hard-coded@objectstack/specentry, and both of its exit paths (skew and no-skew) go through it. So an emptypackagesarray today means a hand-assembled, truncated, or corrupted stamp — not any output this repo produces. The no-skew case is a populated entry withskew: false, which is handled separately and honestly.That also makes it cheap to keep as-is deliberately, which is why this is a
findingand not a queued bug: someone should decide whether "a stamp that asserts nothing" is a state--require-stampought to refuse, or one it is right to tolerate on the same reasoning that lets a no-skew stamp pass.Shape if it is taken
Under
--require-stamponly, treatstamp.packages.length === 0as a refusal with the same remedy block the other--require-stampfailures print. The bare invocation should keep itsℹ, matching how #10428 left the unbuilt-spec notice in place for a bare checkout.Refs: #10428 (the sibling vacuity path, fixed) · #9706 · #9667