fix(spec): ratchet the authorable key surface — the one contract no witness watched (#3855) - #3895
Conversation
…itness watched (#3855) 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 look at 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`. So #3883 removed three authorable keys with every witness green, and #3733 did the same BY ACCIDENT — `dataQuality` / `cached` outlived their keys and were silently stripped. ADR-0059 §5 deferred a deeper gate "until a narrowing actually slips both". It has, twice. `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 because a tombstoned key (`retiredKey()`) 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: 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. The tombstone is audible to whoever hits it, but the change documentation is still empty. Requires a D2 conversion / D3 chain entry naming the surface. 3. An addition is left uncommitted (`--check`). An unrecorded key is invisible to the ratchet forever after — it can only detect the disappearance of something it once saw. Runs in `check:docs` (unconditional, required — 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. Also corrects the `build-api-surface.ts` docblock, which advertised the signature snapshot as answering "did the accepted authoring shape narrow?" — it does not, and believing it did is what let this gap sit. Value-level narrowing (an enum losing a member) stays ungated per ADR-0059 §5's evidence gate, and the docblock now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqC9ckaLbmhmytt65Gxi96
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 104 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
One more piece of evidence, surfaced by the docs-drift check on this PR.
That is precisely the rule this ratchet enforces — and until now nothing backed it. The guarantee was published, the enforcement was absent, and the two gates that looked like they covered it were watching the TypeScript export surface instead. So this is the same Generated by Claude Code |
Follow-up to #3883. Non-breaking — a new gate plus a corrected docblock.
The gap
For a metadata-driven platform the third-party API is what an author may write: the keys inside each schema. Nothing guarded them.
api-surface.jsonname (kind)api-surface-signatures.jsondefineXfactory's typechecker.typeToString(), which prints a type reference (z.input<typeof ActionSchema>) and never expands it structurallyspec-changes.jsonremoved[]api-surface.jsonjson-schema.manifest.jsonAIKnowledgestill exists aftertopicsgoesVerified concretely: #3883 narrowed
defineAction's input by three keys andapi-surface-signatures.jsondid not change — its last commit is still the unrelated #3735.And this is not only a "deliberate change slipped a gate" story. #3733 was the accidental version:
dataQuality/cachedoutlived their keys, stayed discoverable on the API surface and in the reference docs, and an author writing them got a clean parse with the key silently stripped.ADR-0059 §5 named the trigger:
It has now, twice.
The gate
authorable-surface.json— a committed ratchet of all 8588 authorable keys, derived from the walk that already emits the JSON Schemas. One level deeper, no new introspection, one pass.It distinguishes three states for free, because a tombstoned key (
retiredKey(), from #3883) isz.never(), which Zod renders as{ "not": {} }:[RETIRED]Three failure modes, each proven non-vacuous
I simulated all three rather than assuming they fire — the whole point of this PR is that a gate can look present and be dead.
1. A key vanishes with no tombstone — the silent-strip class.
…followed by the full retirement protocol (tombstone → D2/D3 entry →
majorchangeset).2. A key is tombstoned with no registered migration — audible to whoever hits it, but the change documentation is still empty.
This is the rule that mechanizes what I did by hand in #3883 — where nothing would have stopped me skipping it.
3. An addition is left uncommitted (
--checkmode).An unrecorded key is invisible to the ratchet forever after, since it can only detect the disappearance of something it once saw.
Failures
process.exit(1)before the snapshot write, so a red run never clobbers the baseline.Where it runs
check:docs(gen:schema && build-docs --check) already runs in lint.yml's typecheck job, which has no paths filter and is a required check — the job whose own comment says "the gate cannot go dormant". Removal detection lands there for free.check:authorable-surfaceas an explicit step inCheck Generated Artifacts, with thegeneratedpaths filter widened topackages/spec/src/**+ the script + the snapshot — per that filter's own rule that "a path missing here makes that gate dormant on exactly the PRs that can break it, so keep the two in lockstep".Scope, deliberately
Value-level narrowing (an enum losing a member, a union narrowing) stays ungated, per ADR-0059 §5's evidence gate — no such narrowing has slipped yet, and snapshotting it would churn on every internal tweak, which is exactly why the signature hash was scoped to factories in the first place. Noise kills a ratchet faster than a gap does.
What I did fix is the over-promise:
build-api-surface.tsadvertised its signature snapshot as answering "did the accepted authoring shape narrow?". It does not, and believing it did is what let this gap sit. The docblock now states the scope and points at the new ratchet.Testing
@objectstack/spec6818 passed · spectsc --noEmitcleancheck:docs,check:api-surface, pluscheck:spec-changes/check:upgrade-guide/check:liveness/check:skill-refs/check:react-blocksGenerated by Claude Code