Skip to content

Commit c16b04b

Browse files
committed
wip(spec): retire the signature hashes, wire the declaration-text gate
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e81c8a commit c16b04b

11 files changed

Lines changed: 139 additions & 118 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
@@ -5187,11 +5187,16 @@ jobs:
51875187

51885188
# The authorable KEY surface — what a metadata author may write, which for
51895189
# this platform is the third-party API. `api-surface/` records exported
5190-
# names and `api-surface-signatures.json` hashes factory types as TypeScript
5191-
# PRINTS them (a reference, never structurally expanded), so neither sees a
5192-
# key added to or removed from a schema. #3883 removed three authorable keys
5193-
# with every witness green; #3733 did it by accident. ADR-0059 §5 deferred
5194-
# this gate until a narrowing actually slipped both — it has.
5190+
# names only, so it does not see a key added to or removed from a schema;
5191+
# the shape sibling that used to sit beside it (`api-surface-signatures.json`,
5192+
# retired at #16045) hashed factory types as TypeScript PRINTS them — a
5193+
# reference, never structurally expanded — so it did not see one either.
5194+
# #3883 removed three authorable keys with every witness green; #3733 did it
5195+
# by accident. ADR-0059 §5 deferred this gate until a narrowing actually
5196+
# slipped both — it has. `check:api-surface-declarations` (consumer-gates
5197+
# lane) now records the declaration TEXT of every export, which DOES move on
5198+
# such a key; this gate stays the authority on the AUTHORABLE key set, which
5199+
# is a different question from the declared TypeScript shape.
51955200
#
51965201
# ⚠ ORDER: this step must stay ABOVE the `check:docs` step below. Its
51975202
# `--check` run of scripts/build-schemas.ts writes the gitignored
@@ -6113,6 +6118,29 @@ jobs:
61136118
- name: Check @objectstack/spec public API surface
61146119
run: pnpm --filter @objectstack/spec run check:api-surface
61156120

6121+
# [#16045] The SHAPE half of the same surface, and the step the card above
6122+
# exists for: `api-surface/` pins 5336 `name (kind)` rows and a signature
6123+
# change, a renamed interface field and a dropped union member move NONE of
6124+
# them, so 99.5% of the pinned surface could not go red on a breaking shape
6125+
# change to a ratified public type. The declaration-text snapshot records
6126+
# what the packed `.d.ts` actually declares for every export, per entry
6127+
# point. Ruled at #16045 (director batch #60, maintainer 「同意」): text and
6128+
# ⛔ NOT a hash, because a red hash gets accepted rather than investigated
6129+
# and a readable diff is what makes contract review a guard.
6130+
#
6131+
# WHY THIS LANE. It resolves each entry point through the `exports` map to
6132+
# the BUILT `.d.ts` — the declarations a consumer installs — so it is
6133+
# build-dependent and sits after the two build steps above with its family
6134+
# (`check:api-surface`, `check:published-readme-exports`). A missing or
6135+
# stale dist is a HARD REFUSAL in both of the script's modes, never a skip:
6136+
# a build-dependent gate that silently reads nothing reports "not measured"
6137+
# as if it were "measured and clean" (#4690).
6138+
#
6139+
# It adds no required context: a step in an existing lane, so no open PR
6140+
# waits on a check whose name no head has ever reported (#9325).
6141+
- name: Check @objectstack/spec declaration text (the shape half)
6142+
run: pnpm --filter @objectstack/spec run check:api-surface-declarations
6143+
61166144
# [#11350] Consumer-shaped declaration-emit pin against the BUILT root
61176145
# entry (an un-annotated `export default defineStack(...)` must compile
61186146
# 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**

‎packages/spec/api-surface-signatures.json‎

Lines changed: 0 additions & 29 deletions
This file was deleted.

‎packages/spec/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@
240240
"src/**/*.zod.ts",
241241
"CHANGELOG.md",
242242
"api-surface",
243+
"api-surface-declarations",
243244
"spec-changes.json"
244245
],
245246
"scripts": {

‎packages/spec/scripts/build-api-surface.ts‎

Lines changed: 30 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* #2023 class) — and no in-repo consumer catches it, because they all co-evolve
1010
* with the spec in the same commit. See ADR-0059.
1111
*
12-
* Two committed artifacts, both checked in CI:
12+
* One committed artifact, checked in CI:
1313
* - api-surface/<entry>.json — one `name (kind)` row per DECLARED KIND of
1414
* every export of ONE public entry point
1515
* (breadth: did an export disappear?). A
@@ -22,29 +22,35 @@
2222
* because the queue rebuilds server-side with
2323
* no custom merge driver. The root entry `.`
2424
* lands in `root.json`.
25-
* - api-surface-signatures.json — a stable hash of each `defineX` factory's
26-
* resolved signature. Scoped to the factories to
27-
* stay low-noise; full per-export signatures
28-
* would churn on every internal type tweak.
29-
* Deliberately NOT sharded: 1.3KB, one line
30-
* per factory, never a conflict surface.
3125
*
32-
* SCOPE — read before trusting these as a narrowing gate. Both artifacts describe
33-
* the TYPESCRIPT surface. The hash is of `checker.typeToString()`, which prints a
34-
* type REFERENCE (`z.input<typeof ActionSchema>`) and does not expand it, so
35-
* adding or removing a key inside a schema does not move it: #3883 narrowed
36-
* `defineAction`'s input by three keys and this snapshot did not change. The
37-
* authorable KEY surface — which for a metadata-driven platform is the real
38-
* third-party API — is ratcheted separately by `authorable-surface/`
39-
* (scripts/build-schemas.ts, #3855). Value-level narrowing (an enum losing a
40-
* member) is still ungated, per ADR-0059 §5's evidence gate.
26+
* SCOPE — read before trusting this as a narrowing gate. It is the BREADTH half
27+
* and nothing more: a row records that an export EXISTS under a kind, so a
28+
* signature change, a renamed interface field and a dropped union member move no
29+
* row here at all. The SHAPE half is `api-surface-declarations/<entry>.txt`
30+
* (scripts/build-api-surface-declarations.ts) — the declaration TEXT of every
31+
* export, which is where each of those three becomes visible. The authorable KEY
32+
* surface — which for a metadata-driven platform is the real third-party API —
33+
* is ratcheted separately by `authorable-surface/` (scripts/build-schemas.ts,
34+
* #3855). Value-level narrowing (an enum losing a member) is still ungated, per
35+
* ADR-0059 §5's evidence gate.
36+
*
37+
* RETIRED HERE: `api-surface-signatures.json`, a `sha256` of each `defineX`
38+
* factory's `checker.typeToString()`. It was the only shape pin on this surface
39+
* and it covered 27 of 5336 declared rows. It is not merely narrow, it is
40+
* REFERENCE-level — `typeToString` prints `z.input<typeof ActionSchema>` without
41+
* expanding it, so #3883 narrowed `defineAction`'s input by three keys and the
42+
* hash did not move. The declaration-text artifact records the same 27 factory
43+
* declarations (proven covered before the retirement) AND the schemas they point
44+
* at, whose own expanded blocks are where such a narrowing shows up. ⛔ Do not
45+
* reintroduce a digest here: an opaque bit that goes red gets accepted, which is
46+
* the failure mode the text artifact exists to avoid.
4147
*
4248
* pnpm --filter @objectstack/spec gen:api-surface # regenerate + write
4349
* pnpm --filter @objectstack/spec check:api-surface # CI: fail on any drift
4450
*
45-
* A REMOVED export or a CHANGED factory signature is breaking (bump major). An
46-
* ADDED export still requires regenerating, so every change is deliberate. Reads
47-
* the built dist — run after `pnpm --filter @objectstack/spec build`.
51+
* A REMOVED export is breaking (bump major). An ADDED export still requires
52+
* regenerating, so every change is deliberate. Reads the built dist — run after
53+
* `pnpm --filter @objectstack/spec build`.
4854
*
4955
* That last sentence is a PRECONDITION, and since #7122 it is enforced rather
5056
* than merely documented: both modes refuse to read a dist that is missing or
@@ -57,8 +63,7 @@
5763
* tree whose sources were re-checked-out unchanged.
5864
*/
5965
import ts from 'typescript';
60-
import { createHash } from 'node:crypto';
61-
import { readFileSync, writeFileSync } from 'node:fs';
66+
import { readFileSync } from 'node:fs';
6267
import { resolve } from 'node:path';
6368
import { fileURLToPath } from 'node:url';
6469
import { inspectDistFreshness } from './lib/dist-freshness';
@@ -71,7 +76,6 @@ import {
7176

7277
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
7378
const SURFACE_DIR = resolve(PKG_DIR, API_SURFACE_DIR_NAME);
74-
const SIG_SNAPSHOT = resolve(PKG_DIR, 'api-surface-signatures.json');
7579
const CHECK = process.argv.includes('--check');
7680

7781
// BEFORE a single `.d.ts` is read (#7122). Order is the whole point: once
@@ -160,52 +164,20 @@ function buildSurface(): Record<string, string[]> {
160164
return surface;
161165
}
162166

163-
/** Depth: hash of each `defineX` factory's resolved signature (from root). */
164-
function buildSignatures(): Record<string, string> {
165-
const out: Record<string, string> = {};
166-
for (const s of moduleExports(entries['.'], '.')) {
167-
const name = s.getName();
168-
if (!/^define[A-Z]/.test(name)) continue;
169-
const resolved = unalias(s);
170-
if (!(resolved.getFlags() & ts.SymbolFlags.Function)) continue;
171-
const decl = resolved.valueDeclaration ?? resolved.declarations?.[0];
172-
if (!decl) continue;
173-
const type = checker.getTypeOfSymbolAtLocation(resolved, decl);
174-
const str = checker.typeToString(type, decl, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias);
175-
out[name] = 'sha256:' + createHash('sha256').update(str).digest('hex').slice(0, 16);
176-
}
177-
return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]));
178-
}
179-
180167
const surface = buildSurface();
181-
const signatures = buildSignatures();
182168
const surfaceTexts = apiSurfaceShardTexts(surface);
183-
const sigStr = JSON.stringify(signatures, null, 2) + '\n';
184169

185170
if (!CHECK) {
186171
const { written, removed } = writeShards(SURFACE_DIR, surfaceTexts);
187-
writeFileSync(SIG_SNAPSHOT, sigStr);
188172
const total = Object.values(surface).reduce((n, a) => n + a.length, 0);
189-
console.log(
190-
`Wrote ${API_SURFACE_DIR_NAME}/ (${Object.keys(surface).length} entries, ${total} exports) ` +
191-
`and api-surface-signatures.json (${Object.keys(signatures).length} factories).`,
192-
);
173+
console.log(`Wrote ${API_SURFACE_DIR_NAME}/ (${Object.keys(surface).length} entries, ${total} exports).`);
193174
// The locality claim, printed: a PR that changed one entry point's exports
194175
// rewrites one shard, so two such PRs cannot conflict (#5837).
195176
if (written.length > 0) console.log(` touched: ${written.map((n) => `${n}.json`).join(', ')}`);
196177
if (removed.length > 0) console.log(` removed: ${removed.map((n) => `${n}.json`).join(', ')}`);
197178
process.exit(0);
198179
}
199180

200-
function read(path: string, hint: string): string {
201-
try {
202-
return readFileSync(path, 'utf8');
203-
} catch {
204-
console.error(`No snapshot at ${path}. Run \`pnpm --filter @objectstack/spec gen:api-surface\` and commit it (${hint}).`);
205-
process.exit(1);
206-
}
207-
}
208-
209181
let breaking = 0;
210182
let additions = 0;
211183

@@ -246,24 +218,14 @@ if (staleShards.length > 0 || orphanShards.length > 0) {
246218
}
247219
}
248220

249-
// Depth check (factory signatures).
250-
const prevSig: Record<string, string> = JSON.parse(read(SIG_SNAPSHOT, 'depth'));
251-
if (JSON.stringify(prevSig, null, 2) + '\n' !== sigStr) {
252-
for (const name of new Set([...Object.keys(prevSig), ...Object.keys(signatures)])) {
253-
if (!(name in signatures)) { console.error(`\n signature removed: ${name}`); breaking++; }
254-
else if (!(name in prevSig)) { console.error(`\n signature added: ${name}`); additions++; }
255-
else if (prevSig[name] !== signatures[name]) { console.error(`\n signature changed: ${name} (${prevSig[name]} → ${signatures[name]})`); breaking++; }
256-
}
257-
}
258-
259221
if (breaking === 0 && additions === 0) {
260-
console.log('@objectstack/spec public API surface + factory signatures unchanged ✓');
222+
console.log('@objectstack/spec public API surface unchanged ✓');
261223
process.exit(0);
262224
}
263225

264-
console.error(`\n@objectstack/spec public API changed: ${breaking} breaking (removed/narrowed), ${additions} added.`);
226+
console.error(`\n@objectstack/spec public API changed: ${breaking} breaking (removed), ${additions} added.`);
265227
if (breaking > 0) {
266-
console.error('A REMOVED export or a CHANGED factory signature is a BREAKING change for third parties — bump @objectstack/spec to a new major (or restore it).');
228+
console.error('A REMOVED export is a BREAKING change for third parties — bump @objectstack/spec to a new major (or restore it).');
267229
}
268230
console.error('If intentional, run `pnpm --filter @objectstack/spec gen:api-surface` and commit the updated snapshots.');
269231
process.exit(1);

0 commit comments

Comments
 (0)