Skip to content

Commit d8b12fc

Browse files
os-elon-muskclaude
andauthored
feat(spec): pin every export by its .d.ts declaration text, and retire the 27 signature hashes (#18971)
Fixes #16045 Clause-②: yes (widening) Ruled at `5560224701` (director batch #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 #18688 and PR #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 #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 #18948), `scripts/pm/dispatch-gates.mjs` (PR #18903) and `.github/workflows/lint.yml` (PRs #18946, #18889, #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 决策批次 #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>
1 parent a484966 commit d8b12fc

31 files changed

Lines changed: 238310 additions & 119 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`api-surface-declarations/<entry>.txt` — every export of every published entry point now ships a readable pin of the `.d.ts` declaration text the packed build actually emits for it, and the 27-entry `api-surface-signatures.json` hash it subsumes is retired (#16045).
6+
7+
`Clause-②: yes (widening)`
8+
9+
Until now this package pinned its public surface on one axis. `api-surface/<entry>.json` records each export as `name (kind)` — 5336 rows across 17 entry points, re-derived on the landing tree — and a signature change, a renamed interface field and a dropped union member move **none** of them. The only shape pin was `api-surface-signatures.json`: 27 rows, 0.5% of the surface, and reference-level even there, because it hashed `checker.typeToString()`, which prints `z.input<typeof ActionSchema>` without expanding it. A breaking shape change to a ratified public type could pass every witness green.
10+
11+
- **Text, ⛔ not a hash, deliberately.** A digest answers "did the bytes move" with one opaque bit whose known failure at scale is that a red one gets *accepted* rather than investigated. Each shard holds one block per declaration — `// ── Name (kind) ──` followed by the declaration verbatim — so a diff names the export and shows the change, and the existing review discipline is what guards it.
12+
- **The input is the packed `.d.ts` reached through the `exports` map**, i.e. the declarations a consumer installs, never `src/`. Two of the manifest's 19 `exports` entries are asset subpaths with no declaration (`./openapi.json`, `./package.json`), which is why this artifact and `api-surface/` both hold 17 shards.
13+
- **What it costs, measured on the landing tree**: 12,661,943 bytes (12.08 MiB) of text across 17 shards, 237,706 lines, 1.02 MiB gzipped against this package's ~17.6 MiB compressed `dist`. The skew is extreme — the median declaration is 81 bytes and the 20 largest hold ~65% of the bytes, because a Zod schema's packed declaration is its fully expanded structural type. That expansion is exactly what makes an inner field rename visible; it also means four declarations exceed 20,000 lines each.
14+
- **Leading TSDoc is excluded**, so a re-worded `.describe()` does not churn this artifact — documentation drift stays `check:docs`'s axis.
15+
- **The retirement is a strict superset, proven before it landed**: all 27 factory names resolve to a declaration block in `api-surface-declarations/root.txt`, 0 missing. For those 27 declarations text and hash discriminate the same amount (both print a type reference); what is *gained* is the 5309 other declarations, including the schemas those factories point at, whose expanded blocks are where an inner-key narrowing shows up. Nothing published read the retired file: it was not in this package's `files[]`.
16+
- **Sharded per entry point from day one**, for the reason `api-surface/` is: the merge queue rebuilds server-side where no custom merge driver runs, so two PRs sharing one generated file evict the second.
17+
18+
Regenerate with `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations`; `check:api-surface-declarations` names that command when it fails. It reads the built dist, so a missing or stale one is a hard refusal in both modes rather than a green run over nothing.

‎.gitattributes‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ packages/spec/authorable-surface.base.json merge=os-regen
144144
packages/spec/authorable-defaults/** merge=os-regen
145145
packages/spec/json-schema.manifest/** merge=os-regen
146146
packages/spec/api-surface/** merge=os-regen
147+
packages/spec/api-surface-declarations/** merge=os-regen
147148
packages/spec/src/meta-spelling/meta-url-data.generated.ts merge=os-regen
148149
packages/spec/export-origins/** merge=os-regen
149150
packages/spec/declaration-map/** merge=os-regen
150-
packages/spec/api-surface-signatures.json merge=os-regen
151151
docs/protocol-upgrade-guide.md merge=os-regen
152152
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen
153153
content/docs/references/** merge=os-regen

‎.github/workflows/lint.yml‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5161,11 +5161,16 @@ jobs:
51615161

51625162
# The authorable KEY surface — what a metadata author may write, which for
51635163
# this platform is the third-party API. `api-surface/` records exported
5164-
# names and `api-surface-signatures.json` hashes factory types as TypeScript
5165-
# PRINTS them (a reference, never structurally expanded), so neither sees a
5166-
# key added to or removed from a schema. #3883 removed three authorable keys
5167-
# with every witness green; #3733 did it by accident. ADR-0059 §5 deferred
5168-
# this gate until a narrowing actually slipped both — it has.
5164+
# names only, so it does not see a key added to or removed from a schema;
5165+
# the shape sibling that used to sit beside it (`api-surface-signatures.json`,
5166+
# retired at #16045) hashed factory types as TypeScript PRINTS them — a
5167+
# reference, never structurally expanded — so it did not see one either.
5168+
# #3883 removed three authorable keys with every witness green; #3733 did it
5169+
# by accident. ADR-0059 §5 deferred this gate until a narrowing actually
5170+
# slipped both — it has. `check:api-surface-declarations` (consumer-gates
5171+
# lane) now records the declaration TEXT of every export, which DOES move on
5172+
# such a key; this gate stays the authority on the AUTHORABLE key set, which
5173+
# is a different question from the declared TypeScript shape.
51695174
#
51705175
# ⚠ ORDER: this step must stay ABOVE the `check:docs` step below. Its
51715176
# `--check` run of scripts/build-schemas.ts writes the gitignored
@@ -6087,6 +6092,29 @@ jobs:
60876092
- name: Check @objectstack/spec public API surface
60886093
run: pnpm --filter @objectstack/spec run check:api-surface
60896094

6095+
# [#16045] The SHAPE half of the same surface, and the step the card above
6096+
# exists for: `api-surface/` pins 5336 `name (kind)` rows and a signature
6097+
# change, a renamed interface field and a dropped union member move NONE of
6098+
# them, so 99.5% of the pinned surface could not go red on a breaking shape
6099+
# change to a ratified public type. The declaration-text snapshot records
6100+
# what the packed `.d.ts` actually declares for every export, per entry
6101+
# point. Ruled at #16045 (director batch #60, maintainer 「同意」): text and
6102+
# ⛔ NOT a hash, because a red hash gets accepted rather than investigated
6103+
# and a readable diff is what makes contract review a guard.
6104+
#
6105+
# WHY THIS LANE. It resolves each entry point through the `exports` map to
6106+
# the BUILT `.d.ts` — the declarations a consumer installs — so it is
6107+
# build-dependent and sits after the two build steps above with its family
6108+
# (`check:api-surface`, `check:published-readme-exports`). A missing or
6109+
# stale dist is a HARD REFUSAL in both of the script's modes, never a skip:
6110+
# a build-dependent gate that silently reads nothing reports "not measured"
6111+
# as if it were "measured and clean" (#4690).
6112+
#
6113+
# It adds no required context: a step in an existing lane, so no open PR
6114+
# waits on a check whose name no head has ever reported (#9325).
6115+
- name: Check @objectstack/spec declaration text (the shape half)
6116+
run: pnpm --filter @objectstack/spec run check:api-surface-declarations
6117+
60906118
# [#11350] Consumer-shaped declaration-emit pin against the BUILT root
60916119
# entry (an un-annotated `export default defineStack(...)` must compile
60926120
# with `declaration: true` — the TS2883 class). The pin is environment-

‎docs/spec-generated-artifact-sharding.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ arithmetic composes on a merge and the judgement does not).
4141

4242
- **`spec-changes.json`** — keyed by version, so two PRs append under different majors.
4343
Never a conflict surface worth splitting.
44-
- **`api-surface-signatures.json`** — 1.3KB, one line per `defineX` factory.
44+
- **`api-surface-signatures.json`** — RETIRED at #16045, and the one row here whose reason
45+
did not survive its own artifact. It was 1.3KB, one line per `defineX` factory, so it was
46+
never worth splitting. Its replacement is the opposite shape: `api-surface-declarations/`
47+
holds the declaration TEXT of every export (12 MiB across 17 shards on the tree that
48+
landed it), so it is sharded per entry point from the day it arrived, for the same
49+
merge-queue reason `api-surface/` is.
4550
- **`authorable-surface.base.json`** — the #5235 deletion-gate anchor. Nothing but an
4651
explicit `gen:authorable-surface-base` writes it (#5358), so it was never on the churn
4752
path that made the other three the queue's serialization point. It also carries **one**

0 commit comments

Comments
 (0)