Repository navigation
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
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…oid tally in the platform checklist's `pathlessLineCitations` rationale (objectstack-ai#18969) Fixes objectstack-ai#18913 Clause-②: no ## What was wrong `scripts/check-platform-checklist.mjs` justified its own corpus declaring `pathlessLineCitations: true` with a corpus-wide count of what the two path-less spellings admit across `docs/adr/**`, `scripts/**` and `packages/spec/src/**`. That figure was a RAW-REGEX tally. PR objectstack-ai#18884 re-took the reading beside the grammar itself and named the raw-regex pair void: it subtracts neither the fenced blocks the extractor skips nor the citations the path-anchored passes had already recorded, so it overstates what the option actually admits by about a half. The aggravating half is where the number sat: inside the file's own stated rationale for a live configuration decision, a dozen lines above the `defineCorpus` call it argues for. An author reading it to decide whether their corpus should opt in was reading a cost the same repository had already withdrawn. ## Premise verification, done first The card's load-bearing claim — that objectstack-ai#18884 named that figure void — was the one thing the dispatching seat had deliberately NOT checked. Confirmed before any edit: - The re-take text and the registration-time refusal both arrive in the same commit, and that commit names the PR: `0a5851d2` "fix(scripts): refuse `pathlessLineCitations` on a corpus that declares a doc projection (objectstack-ai#18884)" (`git log -S` over `scripts/symbol-anchors.mjs`; a positive reading, so the shallow checkout does not weaken it). - The card's census reproduces on this tree. `git grep -nE '245 times|222 and 23|222 \+ 23|222\+23'` over `*.mjs *.ts *.mts *.md *.mdx *.json` returns exactly one hit, the rationale line itself; the control the card names, `112 and 25`, returns exactly one hit in `scripts/symbol-anchors.mjs`, so the one is a reading and not a dumb instrument. - This is the only corpus in the repository declaring the option. The five registered corpora were enumerated; four declare nothing, and the default in `scripts/symbol-anchors.mjs#extractAnchors` is `false`. Nothing here generalises, and nothing here was applied to another corpus. ## Does the corrected figure change the decision it justified? No, and this was checked before the prose was touched, because flipping a corpus declaration under cover of a prose fix is exactly what this card must not become. The old sentence carried two jobs. The qualitative one — in prose a colon before digits is punctuation, so the option admits ports, scenario labels and docblock back-references — survives the re-take unchanged; the re-take's own text enumerates the same three shapes. The quantitative one halves but keeps its sign, so "default OFF is a measurement" still holds. What actually changed is that the reason this corpus may opt in is no longer a cost reading at all. `scripts/symbol-anchors.mjs#defineCorpus` now REFUSES the option, at registration, to any corpus declaring a `docProjection` (objectstack-ai#18845), and the sharper half of the same re-take is that the projected corpora resolve the option into unjudged residual and no actionable finding. This corpus declares no projection and sweeps its documents raw, so the refusal does not reach it. That is a mechanical test the registration passes, which is strictly better than the prose cost argument it replaces. ## The repair Triage asked for a bounded transfer rather than a second absolute number ("绝对读数会腐烂,有界转移不会"), and the card asked that a successor not swap one transcribed reading for another. Both are satisfied by citing rather than restating: - The qualitative claim stays in this file. - The reading stays with the grammar that admits the spellings, cited as `scripts/symbol-anchors.mjs#PATHLESS_COLON_CITATION` — the docblock that carries the re-take, and that names the tree it was taken against, which this entry never did. - The live legality test — the registration-time refusal, and this corpus declaring no projection — is stated where the cost reading used to be. No new corpus-wide figure is written here. The point of the card is that a digit retyped into a second file is gated by nothing; a symbol anchor in a `scripts/**` comment is resolved on every PR by `check:scripts-symbol-anchors`, so the pointer reds if its target is renamed or deleted. ## Reverse verification of the pointer The claim "this transfer is gated, the digit was not" is itself measured, from the committed state, predicting a red: Mutant leg — the anchor's symbol replaced with one that does not exist, via `scripts/ablation-replace.mjs --expect 1` so the anchor must hit; proven on disk before reading any verdict (real anchor occurrences 1 to 0, mutant 0 to 1, blob `91d4962f` to `0c98d3cd`): ``` check-scripts-symbol-anchors EXIT=1 ❌ check-scripts-symbol-anchors: 1 finding(s) across 264 scripts. [unresolved-symbol] scripts/check-platform-checklist.mjs:1565 `PATHLESS_COLON_CITATION_NO_SUCH_SYMBOL` has no declaration site or string-literal token in `scripts/symbol-anchors.mjs` ``` Restore leg — `git checkout HEAD -- scripts/check-platform-checklist.mjs` under an `EXIT INT TERM` trap on an absolute path; `git diff HEAD` prints zero bytes and `git hash-object` returns `91d4962f`, byte-identical to the HEAD blob. Declared: the FIRST ablation attempt was a NO-OP. It passed flag names the tool does not take (`--find`/`--replace` instead of `--anchor`/`--replacement`), the tool printed its usage, and the on-disk blob was unchanged — the reading from that run is void and is not counted. It is reported rather than quietly re-run. No build or `dist/` is in this path: these are plain `.mjs` gates resolved from source, so on-disk blob change is the whole preflight. ## A second stale count in the same block, fixed in place The list this entry belongs to was introduced as "Two declarations this corpus needed and the core did not have:" and has carried THREE items since the `pathlessLineCitations` fold was appended to it — the entry is tagged `(objectstack-ai#18592)` while the other two came from objectstack-ai#18107, so the count was simply never updated. That is the same defect class as the card (a number in this rationale block that the file itself contradicts) and the fix is one word, in the block already named on the claim, adding no verification surface. Changed to "Three". Evidence is the list itself: `unspannedAnchors`, `excludeDirs`, `pathlessLineCitations`. ## Tests Comment-only change to one file. Every reading below was taken on this branch at `5f63efc5`, after the merge of `origin/main`, with `pnpm install --frozen-lockfile` re-run for the lockfile the merge moved. `node scripts/pm/dispatch-gates.mjs --commands` derived 29 gate commands from the change set; all 29 were run and recorded as `command :: exit code`, and `--ran` reconciles them: "29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED (a DERIVED zero — all 29 recorded an exit code and none of them is 3)". Every exit code is 0. The derivation is byte-identical before and after the merge, so the pre-merge sweep and this one cover the same families. `check:nul-bytes` is green and a direct control-byte scan of the edited file finds none. `pnpm lint` is the repo-wide scan CI owns. Targeted run on the edited file: 0 errors, 0 warnings, 1 file from `--format json`. The narrowing is sound because this repository never enables type-aware linting — `eslint.config.mjs` says so in its own words, "this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not" — so a comment-only edit inside one file cannot move the verdict on any file it does not contain. ## Acceptance notes Nothing to file. The only out-of-scope thing this work turned up is the "Two" versus three miscount above, and it was repaired in place rather than noted, under the bounded in-place rule: same defect class as the card, one word, mechanically determined, inside the block the claim already names, and adding no verification surface. It is called out in its own section rather than buried in the diff. The two fenced files were not touched: `scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948) and `scripts/check-sdui-manifest.mjs` (objectstack-ai#18879) are absent from this diff, which carries exactly one path. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…e the 27 signature hashes (objectstack-ai#18971) Fixes objectstack-ai#16045 Clause-②: yes (widening) Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a readable declaration-text snapshot, ⛔ not a hash. The card body's three mutually exclusive routes predate that ruling and were not re-litigated here. `@objectstack/spec` pinned its public surface on one axis. `api-surface/` records each export as `name (kind)`, and a signature change, a renamed interface field and a dropped union member move **none** of those rows. The only shape pin was `api-surface-signatures.json`: 27 rows, and reference-level even there. This adds `api-surface-declarations/`, the declaration text the packed build actually emits for every export of every published entry point, and retires the 27 hashes it subsumes. ## The counts, re-derived on this head before the first generation The ruling asks for this by name; the card's own numbers were self-declared unverified and 12 days old. | Number | Card | This head (`b33898f5d`) | Unit, and what would make it something else | |---|---|---|---| | entry points | 17 | **17** | type entry points in the `exports` map — those whose `require.types` ends in `.d.ts`. Adding or removing one such subpath. | | `exports` map entries | (not stated) | **19** | every key in the map. The extra two are `./openapi.json` and `./package.json` — asset subpaths with no declaration at all, filtered out by the same `.d.ts` test `build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is right and the map did not grow**; 19 counts two things that were never entry points. | | pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17 `api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 = 0.51%, so the headline 99.5% stands. | | distinct exported names | (not stated) | **5200** | (entry, name) pairs. The gap to 5336 is dual-declared names, which are two rows by design. | | signature hashes | 27 | **27** | top-level keys of `api-surface-signatures.json`. Bright control: the first value really is a `sha256:` string, so this counts signature entries and not empty objects. | Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve through the map (3,215,437 bytes for the root entry down to 13,081 for `./integration`). No entry point lacks a packed declaration, so the gap the dispatch reserved for itself did not open. ## What the artefact costs — premise 4, which nobody had costed | | | |---|---| | shards | 17, one per entry point | | declaration blocks | 5336 | | bytes | **12,661,943 (12.08 MiB)** | | lines | **237,706** | | gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB compressed `dist`, so about **+5.8%** of tarball | | largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines | | median declaration | **81 bytes** | | skew | the 20 largest declarations hold **~65%** of all bytes; four exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868, `ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851, `ChangeSetSchema` 20,395) | Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed `.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its **fully expanded** structural type. That expansion is exactly what makes an inner field rename visible — and it is also why a single schema can produce a 21,000-line diff. The ruling's stated reason for choosing text over a hash is that the contract-review seat reads the diff; that reasoning holds per declaration and is worth a second look at the top twenty. One reading, for whoever wants it: 31% of declarations hold 97.7% of the bytes, so nothing cheap is available by trimming the tail. ## Both instruments, measured on one tree at one commit The card's thesis is that the old pin cannot fail on a shape change. Not argued — ablated, with the mutation proven on disk by blob hash and the mutation proven to have reached `dist/` before any verdict was read. **A. the source-level control — a renamed interface field, the card's own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in `packages/spec/src/contracts/job-service.ts` (blob `363443e2` to `d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming the marker reached the built artefact: ``` check:api-surface exit=0 "public API surface unchanged" [BLIND] check:api-surface-declarations exit=1 "~ JobRunOutcome (interface)" [SEES IT] ``` Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight --absent` exit 0 (marker gone from all 214 built files), `git diff HEAD` clean, gate back to exit 0. **B. the gate can fail on its own artefact.** One field renamed inside `qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1, deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`, failure text naming the regenerate command. Restored to the HEAD blob, `git diff HEAD` empty: exit **0**. ## The retirement, and the coverage proof the ruling demands All **27** signature names resolve to a declaration block in `api-surface-declarations/root.txt`, **0 missing** — enumerated from `defineAction` through `defineWebhook`, each as `(function)`. One honest qualification, because the subsumption is not uniform. For those 27 factory declarations the text is `declare function defineAction(config: z.input of ActionSchema): ActionParsed;` — a type **reference**, exactly as blind to an inner-key narrowing as `typeToString` was. What is gained is not sharper text on the 27; it is the **5309 other declarations**, including `ActionSchema` itself, whose own expanded block is where such a narrowing shows up. So the retirement is a strict superset of pinned declarations, not an equal trade. Nothing published read the retired file — it was never in this package's `files[]`. ## Where it lands, and why there - Generator: `packages/spec/scripts/build-api-surface-declarations.ts`, beside the eight sibling artefact generators, reading the same input through the same `collectEntries` logic. The ruling says "one generator script under `scripts/`"; this reads that as the directory the whole family lives in, because the artefact reads the **built dist** and only the lane that builds spec can run its gate. - Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a sibling **directory** of `api-surface/`. Not inside it: `listShardNames` throws on any file in that directory that is not a `NAME.json` shard, so `api-surface/` is closed by construction. No existing `api-surface/*.json` is regenerated by this PR (`check:api-surface` green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319. - Gate: `check:api-surface-declarations`, a step in lint.yml's `Type Check · consumer gates` lane after the two build steps, with `check:api-surface` and the other dist-reading gates. **No new required context** — a step in an existing lane. Registered in the `check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes` as `merge=os-regen`. - Sharded per entry point from day one, for the reason its neighbour is: the merge queue rebuilds server-side where no custom driver runs, so two PRs sharing one generated file evict the second. Pit 1 from `5715457322` is answered by the layout rather than by an assumption — and `check:merge-driver`, which reconciles `.gitattributes` against `REGEN_ARTIFACTS` in both directions, is green over the swap. - Published, with the reason the gate demands. `check:published-files` refuses a `files[]` entry that carries none; the registered line says what a consumer does with it — read two published tarballs and see *which declared shape* moved between releases, the question `api-surface` cannot answer. If 1.02 MiB of tarball is judged too much, one line of `files[]` removes it without touching anything else. Three registries had to learn about the new gate, each because it discovered the gate on its own rather than because a list named it: - `check:published-files` — demanded the reason above. - `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new gate a population before anything listed it, which is the eighth member of a class whose seventh was recorded the same way. Declared as `CLASS_EIGHTH`, with a case asserting the edge really reaches it. - `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped it off that surface and reddened two self-test cases. Both are retargeted to state the retirement as a counterfactual (the surface follows the table, not a literal); ⛔ the new artefact is **not** added to that surface, because the ruling assigns "is a snapshot diff a Clause-② signal" to the skills seat by name and out of this card's scope. Both directions are now pinned, so the boundary is declared rather than forgotten. 483 cases pass, up from 481. ## Verification - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the merge base, 120 commands, every exit code redirected to a file and read back. **All 120 green.** Four returned exit **3** PREREQUISITE NOT MET on first pass (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`); each names a build, each was built and re-run green, and none is recorded as a finding. Reconciled with `--ran`. - **Tests**: `@objectstack/spec` local project **488 files / 14,182 tests passed**; the tooling suites that name the edited scripts, both projects, **10 files / 220 tests passed** (`sharded-artifacts`, `check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`, `api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`, `def-key-collisions`, `root-index`, `export-list`, `docs-import-surface`). `pnpm --filter @objectstack/spec typecheck` green. - **eslint, the union rather than a narrowing**: `eslint . --no-inline-config --format json` at `b33898f5d` examined **6856 files**, **0 errors, 0 warnings**, exit 0. The population is eslint's own config resolution and the count is read from its JSON output; type-aware linting is not enabled in `eslint.config.mjs` (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict either way. - **Control bytes**: `check:nul-bytes` green over 8906 files, plus a direct scan of all 31 changed paths for the wider control-byte class — no matches. - `scripts/check-single-claim-paths.mjs` in the diffstat is **not mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`) this PR carries. ## Acceptance notes - `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists `api-surface-signatures` as an instance of a retirement shape, and that row goes stale with this landing. ⛔ Left untouched on purpose: `.claude/**` is a governed surface, so editing it would make this whole PR maintainer-landed for a one-word prose nit. Noted, not filed. - `packages/spec/scripts/build-schemas.ts` line 830 carries the same stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted, not filed, with the later lander as the natural carrier. - Three files in this diff are held by open PRs and were edited anyway because the retirement forces it, not by choice: `scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948), `scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and `.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are hand-written files where a text conflict is visible rather than silent, and all three of my hunks are small and far from theirs. Whoever lands second resolves. - The top-20 skew above is a reading, not a finding: no gate is wrong and nothing is unenforced. It is recorded here because the ruling's own justification for text over hash is per-declaration readability, and at 21,000 lines a declaration that argument thins out. ## 维护者速读(草稿) **改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后 `.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI 闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。 **为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27 行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 —— 全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface` 退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60 里您逐字「同意」的那一条。 **风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约 5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2 万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从 `files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。 **席位意见。** **你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff 可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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