fix(pm): check-widening-tells reports a key re-typed INTO a universal acceptor - #18948
Merged
os-steve merged 1 commit intoSep 18, 2026
Merged
Conversation
… acceptor #16943's replacement budget confirms that a key was rewritten and never asks whether the rewrite accepts more or less. That direction blindness made a real widening silent: `filter: z.union([A, B])` replaced by `filter: z.unknown()` earned a T1 unit on the removed line and spent it on the added one, so the gate declined while both of the filing card's controls (a brand-new key, an enum widened in place) fired on the same harness. #18234 supplied the one direction fact a line can carry, on the REMOVED side. This is that same fact read on the ADDED side: the added line declares the key as a universal acceptor by `declaresUniversalAcceptorKey` — the same reading, so the two ends of the budget cannot drift apart — and a removed line names the SAME key carrying no acceptor call at all. Absence of evidence still leaves the budget paying, as everywhere else in this file. The widening SPENDS its unit and is reported on top of it. Refusing the spend would hand the unit to the next added line, silencing a genuine new key riding along with the widening — the surplus rule inverted. The `#18234` battery asserted this silence on the stated ground that the case "is what reds the day that lands"; it did, and it is replaced by the case that asserts the inverse now fires while #18234's own direction still declines. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 18, 2026
os-steve
marked this pull request as ready for review
September 18, 2026 07:51
os-steve
deleted the
claude/issue-18629-widening-tells-reverse-direction
branch
September 18, 2026 08:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #18629
Clause-②: no
check-widening-tellsnow reports a key re-typed into a universal acceptor. #16943's replacement budget is stated in its own words as "replacement-vs-net-addition, not spelling", and that arithmetic is deliberately directionless: it confirms that the same key was rewritten and never asks whether the rewrite accepts more or less. That blindness made a real widening silent.Premise, re-derived on the current tree
The dispatch warned that the merged PR #18920 moved this file by +165 lines, so every line reference on the card is stale. The defect was re-located and re-measured on the branch base
54145cce, with the card's own two controls lit on the same harness:filter: z.union([A, B])re-typed tofilter: z.unknown().optional()z.enumwidened in place (must fire)Both controls fire under both readings, so that
declinesis a reading and not a mis-scoped probe. The premise holds exactly as filed.The repair
#18234 supplied the one direction fact a line can carry, on the REMOVED side: a removed value that accepted everything proves the replacement is a subset by construction. This is that same fact read on the ADDED side, and the two are one predicate seen from either end.
widensKeyIntoUniversalAcceptorasks for two facts the change block carries:declaresUniversalAcceptorKey— the same reading [finding] check-widening-tells C5: narrowing a Zod key OUT ofz.unknown()fires T1 when the new value opensz.union(and declines when it does not — #17618's three-fact spend cannot express "the removed value accepted everything" #18234 certifies a removed one with, so the two ends of the budget cannot drift apart about what "accepts everything" means; andAbsence of evidence still leaves the budget paying, exactly as absence of evidence leaves a tell firing everywhere else in this file. This is not a change to the budget for every key, which is what the card and #18234's own section both predicted the repair would take.
Fact 2 is a MENTION rather than "not certified by fact 1", and that is the precision. 33 of this tree's 132 universal-acceptor key lines do not terminate on their own line, so negating fact 1 would fire on a pure reformat of an already-universal key — a false positive on a diff that changes no accept set at all. This gate is PM infrastructure and a false positive here blocks other seats' cards, so the reading declines there instead and the cost is stated in the header with its overturn condition.
The widening still SPENDS its unit and is reported on top of it. Refusing the spend would hand the unit to the next added line in the block, so a genuine new key riding along with the widening would go silent — this file's surplus rule inverted, one report bought at the price of another. Pinned: a block spelling
- filter: z.union([A, B]),+ filter: z.unknown(),+ other: z.string(),reports BOTH rows, each with its own file:line.The battery case that had to red
#18234's battery asserted this silence on the stated ground that the case "is what reds the day that lands". It did: the first self-test run after the one-line wiring failed exactly one case of 481, that one. It is replaced by the case asserting the inverse now fires while #18234's own direction still declines — so a repair of the inverse cannot be bought by breaking the narrowing side.
The new battery is 27 cases: 15 firing, 6 declining controls (neutral re-spelling, reformat, mention-inside-narrower, wrapped added chain, rename onto a new key, and #18234's live pair still clean), 6 on the reader itself. Self-test total 481 → 508.
Reverse verification
Ablation from the committed state, the on-disk write proven by
scripts/ablation-replace.mjs(anchor 1 → 0, blob31cb9de0f295→2a87037c1241), then restored to a byte-identical tree (hash back to31cb9de0f295,git diff HEADempty):if (!widensIntoAcceptor) continue;back tocontinue;— reds 14 self-test cases: the whole firing half plus the replaced [finding] check-widening-tells C5: narrowing a Zod key OUT ofz.unknown()fires T1 when the new value opensz.union(and declines when it does not — #17618's three-fact spend cannot express "the removed value accepted everything" #18234 case. The declining half stays green, which is where it belongs: those declines do not depend on the mutated line.No build step sits in this path (the gate runs from source as a node script), so no
distpreflight applies.Blast radius — how many existing diffs newly fire
Zero, on both boards this gate judges, with the harness control lit.
objectstack-ai/objectstackd83d079b2026-09-06T18:43Z …e295d68aobjectstack-ai/objectui3e4f6322026-09-18T05:06ZCounting rows whose
whycarries the new wording is exact here rather than approximate: the only behavioural change is the one wired line, so a newly-reported row is precisely a row where the predicate answered true and the budget would otherwise have paid. The objectui sweep resolved no file-local declaring forms (its blobs are not in this object store), which cannot move this count — the new reading is line-local and never consults them.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 29 families from the one changed path. All 29 were run, each exit code captured before any pipe; all 29 exit 0. Reconciled with--rancarryingcommand :: exit N, so its0 NOT-MEASUREDis a derived zero rather than the runner's claim.Eight of the 29 are
--self-test-only invocations that judge a script's own battery against fixtures and so cannot judge this diff's content either way:check-closing-keyword-parity,check-declaration-mirrors,check-scripts-symbol-anchors,check-self-test-wired,check-self-test-workflow-commands,check-whole-set-label-write,bare-root-worklist, andcheck:pm-widening-tells. The last is the exception worth naming: it ischeck-widening-tells.mjs --self-test, and since this diff is that file, its battery is the direct judge of the change rather than a bystander.Beyond the derived set:
pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowing) — exit 0 ate295d68a, 68s.check-clause2-carriers, 941 cases;check-half-states, 4963;check-prior-rulings, 99) — all exit 0.This diff's own clause-② reading is NOT MEASURED, honestly:
scripts/pm/**is covered by no declared surface, so the gate's exit 0 on it is evidence about no surface at all.Changeset
skip-changeset, measured rather than assumed. The only changed file lives in the private root package (@objectstack/spec-monorepo,private: true, nofiles[]), and no package'sfiles[]namesscripts/. Grepping the new symbol across all 126 existing published paths of every non-private package returns 0 hits, with the positive controldefineStackreturning 45 over the same path set. Nothing published moves.Acceptance notes
scripts/pm/check-widening-tells.mjsis not a governed surface (the register holdsdocs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md), so this PR carries no maintainer-approval fence.- other: z.string(),/+ filter: z.unknown(),— is still paid for by [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943's budget. That is [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943's own one-for-one rename silence, already declared in its battery as a QUIET DIRECTION together with the gates that still catch it (check:api-surfaceon any exported type it moves,check:authorable-surfaceon any authorable key, and the ADR-0087 registries). Carrier: that existing case; no new card.packages/spec/scripts/check-duration-unit-keys.tswas not touched (a sibling seat holds it), and no file outside the declared surface was edited.Generated by Claude Code