Skip to content

Commit 8f9689f

Browse files
authored
fix(spec): ratchet the authorable key surface — the one contract no witness watched (#3855) (#3895)
For a metadata-driven platform the third-party API is WHAT AN AUTHOR MAY WRITE: the keys inside each schema. Nothing guarded them. Both existing witnesses watch the TypeScript surface instead. `api-surface.json` records exported `name (kind)`, and a key inside a schema is not an export. `api-surface-signatures.json` hashes each `defineX` factory's type — but via `checker.typeToString()`, which prints a type REFERENCE (`z.input<typeof ActionSchema>`) and never expands it, so member-level narrowing cannot reach the hash. `spec-changes.json` inherits the blind spot: its added/removed arrays are a diff of `api-surface.json`. Verified concretely: #3883 narrowed `defineAction`'s input by three keys and the signature snapshot did not move. And #3733 was the ACCIDENTAL version — `dataQuality` / `cached` outlived their keys, stayed discoverable in the reference docs, and were silently stripped on parse. ADR-0059 §5 deferred a deeper gate "until a narrowing actually slips both". It has, twice. `content/docs/protocol/backward-compatibility.mdx` had meanwhile been promising third parties the exact rule nothing enforced: "removing or renaming properties is a MAJOR change". `authorable-surface.json` ratchets all 8588 authorable keys, derived from the walk that already emits the JSON Schemas — one level deeper, no new introspection. Three states, distinguishable for free because a tombstoned key (`retiredKey()`, #3883) is `z.never()`, which Zod renders as `{ "not": {} }`: live, [RETIRED], absent. Three failure modes now fail the build, each verified non-vacuous by simulation rather than assumed — the whole point being that a gate can look present and be dead: 1. A key vanishes with no tombstone. These schemas are not `.strict()`, so Zod silently strips an unknown key: clean parse, setting never takes effect. The error spells out the retirement protocol. 2. A key is tombstoned with no registered migration. Audible to whoever hits it, but the change documentation is still empty. Requires a D2 conversion / D3 chain entry naming the surface — mechanizing what #3883 did by hand with nothing to enforce it. 3. An addition is left uncommitted (`--check`). An unrecorded key is invisible to the ratchet forever after, since it can only detect the disappearance of something it once saw. Failures exit before the snapshot write, so a red run never clobbers the baseline. Runs in `check:docs` (unconditional, required — the job whose own comment says the gate cannot go dormant) and as an explicit step in Check Generated Artifacts, with the paths filter widened in lockstep per that filter's own stated rule. Value-level narrowing (an enum losing a member) stays ungated per ADR-0059 §5's evidence gate; noise kills a ratchet faster than a gap does. What is fixed is the over-promise: `build-api-surface.ts` advertised its signature snapshot as answering "did the accepted authoring shape narrow?" — it does not, and believing it did is what let this gap sit.
1 parent 9ea2bc5 commit 8f9689f

6 files changed

Lines changed: 8844 additions & 5 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): ratchet the authorable key surface — the one contract no witness watched (#3855)
6+
7+
For a metadata-driven platform the third-party API is **what an author may
8+
write**: the keys inside each schema. Nothing guarded them.
9+
10+
The two existing witnesses look at the TypeScript surface instead:
11+
12+
- `api-surface.json` records exported `name (kind)`. A key inside a schema is
13+
not an export, so removing one never moves it.
14+
- `api-surface-signatures.json` hashes each `defineX` factory's type — but via
15+
`checker.typeToString()`, which prints a type *reference*
16+
(`z.input<typeof ActionSchema>`) and never expands it structurally. Member-level
17+
narrowing cannot reach the hash.
18+
19+
`spec-changes.json` inherits the same blind spot: its `added`/`removed` arrays
20+
are a diff of `api-surface.json`.
21+
22+
So #3883 removed three authorable keys with every witness green — and #3733 did
23+
the same **by accident**, when `dataQuality` / `cached` outlived their keys and
24+
were silently stripped. ADR-0059 §5 deferred a deeper gate "until a narrowing
25+
actually slips both". It has, twice.
26+
27+
**New: `authorable-surface.json`**, a committed ratchet of all 8588 authorable
28+
keys, derived from the same walk that already emits the JSON Schemas — one level
29+
deeper, no new introspection. It distinguishes three states, because a tombstoned
30+
key (`retiredKey()`) is `z.never()`, which Zod renders as `{ "not": {} }`:
31+
32+
| State | Meaning |
33+
|---|---|
34+
| live | a normal property — the author may write it |
35+
| `[RETIRED]` | present but unwritable, carrying its own upgrade prescription |
36+
| absent | gone from the contract with nothing left to say |
37+
38+
Three failure modes now fail the build, each verified non-vacuous by simulation:
39+
40+
1. **A key vanishes without a tombstone.** These schemas are not `.strict()`, so
41+
Zod silently strips an unknown key: the author gets a clean parse and a
42+
setting that never takes effect. The error spells out the retirement protocol.
43+
2. **A key is tombstoned with no registered migration.** The tombstone is audible
44+
to whoever hits it, but the change documentation — `spec-changes.json`, the
45+
generated upgrade guide, the `spec_changes` MCP tool, `os migrate meta` — is
46+
still empty. Requires a D2 conversion / D3 chain entry naming the surface.
47+
3. **An addition is left uncommitted** (`--check`). An unrecorded key is
48+
invisible to the ratchet forever after, since it can only detect the
49+
disappearance of something it once saw.
50+
51+
It runs in `check:docs` (unconditional, required — cannot go dormant) and as an
52+
explicit `check:authorable-surface` step in `Check Generated Artifacts`, with the
53+
paths filter widened in lockstep per that filter's own rule.
54+
55+
Also corrects the `build-api-surface.ts` docblock, which advertised the signature
56+
snapshot as answering "did the accepted authoring shape narrow?" — it does not,
57+
and believing it did is what let this gap sit. Value-level narrowing (an enum
58+
losing a member) remains ungated, per ADR-0059 §5's evidence gate.

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ jobs:
6262
- 'packages/spec/api-surface.json'
6363
- 'packages/spec/spec-changes.json'
6464
- 'docs/protocol-upgrade-guide.md'
65+
# check:authorable-surface — the ratchet's input is every Zod schema
66+
# in the spec, so the filter has to be that broad or the gate goes
67+
# dormant on exactly the PRs that remove an authorable key (#3855).
68+
- 'packages/spec/src/**'
69+
- 'packages/spec/scripts/build-schemas.ts'
70+
- 'packages/spec/authorable-surface.json'
6571
- '.github/workflows/ci.yml'
6672
core:
6773
- 'packages/**'
@@ -558,3 +564,13 @@ jobs:
558564

559565
- name: Check the protocol upgrade guide is regenerated with the ADR-0087 registries
560566
run: pnpm --filter @objectstack/spec check:upgrade-guide
567+
568+
# The authorable KEY surface — what a metadata author may write, which for
569+
# this platform is the third-party API. `api-surface.json` records exported
570+
# names and `api-surface-signatures.json` hashes factory types as TypeScript
571+
# PRINTS them (a reference, never structurally expanded), so neither sees a
572+
# key added to or removed from a schema. #3883 removed three authorable keys
573+
# with every witness green; #3733 did it by accident. ADR-0059 §5 deferred
574+
# this gate until a narrowing actually slipped both — it has.
575+
- name: Check the authorable key surface is recorded and nothing vanished
576+
run: pnpm --filter @objectstack/spec check:authorable-surface

0 commit comments

Comments
 (0)