Repository navigation
Commit d8b12fc
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
File tree
- .changeset
- .github/workflows
- docs
- packages/spec
- api-surface-declarations
- scripts
- lib
- scripts
- pm
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
144 | 144 | | |
145 | 145 | | |
146 | 146 | | |
| 147 | + | |
147 | 148 | | |
148 | 149 | | |
149 | 150 | | |
150 | | - | |
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5161 | 5161 | | |
5162 | 5162 | | |
5163 | 5163 | | |
5164 | | - | |
5165 | | - | |
5166 | | - | |
5167 | | - | |
5168 | | - | |
| 5164 | + | |
| 5165 | + | |
| 5166 | + | |
| 5167 | + | |
| 5168 | + | |
| 5169 | + | |
| 5170 | + | |
| 5171 | + | |
| 5172 | + | |
| 5173 | + | |
5169 | 5174 | | |
5170 | 5175 | | |
5171 | 5176 | | |
| |||
6087 | 6092 | | |
6088 | 6093 | | |
6089 | 6094 | | |
| 6095 | + | |
| 6096 | + | |
| 6097 | + | |
| 6098 | + | |
| 6099 | + | |
| 6100 | + | |
| 6101 | + | |
| 6102 | + | |
| 6103 | + | |
| 6104 | + | |
| 6105 | + | |
| 6106 | + | |
| 6107 | + | |
| 6108 | + | |
| 6109 | + | |
| 6110 | + | |
| 6111 | + | |
| 6112 | + | |
| 6113 | + | |
| 6114 | + | |
| 6115 | + | |
| 6116 | + | |
| 6117 | + | |
6090 | 6118 | | |
6091 | 6119 | | |
6092 | 6120 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
45 | 50 | | |
46 | 51 | | |
47 | 52 | | |
| |||
0 commit comments