Skip to content

fix(pm): check-widening-tells reports a key re-typed INTO a universal acceptor - #18948

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-18629-widening-tells-reverse-direction
Sep 18, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-18629-widening-tells-reverse-direction

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #18629

Clause-②: no

check-widening-tells now 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:

probe before after
INVERSE — filter: z.union([A, B]) re-typed to filter: z.unknown().optional() exit 0, declines exit 4, T1
CONTROL — a brand-new key (must fire) exit 4, T1 exit 4, T1
CONTROL — z.enum widened in place (must fire) exit 4, T1 exit 4, T1

Both controls fire under both readings, so that declines is 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. widensKeyIntoUniversalAcceptor asks for two facts the change block carries:

  1. the added line declares the key as a universal acceptor, by declaresUniversalAcceptorKey — the same reading [finding] check-widening-tells C5: narrowing a Zod key OUT of z.unknown() fires T1 when the new value opens z.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; and
  2. a removed line names the SAME key and carries no universal-acceptor call at all.

Absence 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, blob 31cb9de0f295 → 2a87037c1241), then restored to a byte-identical tree (hash back to 31cb9de0f295, git diff HEAD empty):

No build step sits in this path (the gate runs from source as a node script), so no dist preflight applies.

Blast radius — how many existing diffs newly fire

Zero, on both boards this gate judges, with the harness control lit.

board window newly reported rows
objectstack-ai/objectstack 1219 commits, d83d079b 2026-09-06T18:43Z … e295d68a 0
objectstack-ai/objectui 1050 commits, … 3e4f632 2026-09-18T05:06Z 0
CONTROL — the card's own patch through the same sweep code path — 1

Counting rows whose why carries 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 --commands derives 29 families from the one changed path. All 29 were run, each exit code captured before any pipe; all 29 exit 0. Reconciled with --ran carrying command :: exit N, so its 0 NOT-MEASURED is 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, and check:pm-widening-tells. The last is the exception worth naming: it is check-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 at e295d68a, 68s.
  • the three other modules in this subtree, one of which imports this one (check-clause2-carriers, 941 cases; check-half-states, 4963; check-prior-rulings, 99) — all exit 0.
  • control-byte self-scan over the changed file — no hits.

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, no files[]), and no package's files[] names scripts/. Grepping the new symbol across all 126 existing published paths of every non-private package returns 0 hits, with the positive control defineStack returning 45 over the same path set. Nothing published moves.

Acceptance notes


Generated by Claude Code

… 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>
@os-steve os-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@os-steve
os-steve marked this pull request as ready for review September 18, 2026 07:51
@os-steve
os-steve added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit d8fac9a Sep 18, 2026
37 checks passed
@os-steve
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>
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