Skip to content

Commit 2882528

Browse files
claude[bot]claude
andauthored
fix(spec): the authorable-surface reachability roots include the unregistered kind schemas (#18131)
Fixes #17356 Clause-②: yes ## What was wrong `computeSurfaceReachability()` in `packages/spec/scripts/build-schemas.ts` — the whole of check (c) **proof 2** — built its BFS root set from `listMetadataTypeSchemaTypes()`. That function unions `BUILTIN_METADATA_TYPE_SCHEMAS` with the `EXTRA_METADATA_TYPE_SCHEMAS` overlay and, per **#6245**, pointedly does **not** enumerate `UNREGISTERED_KIND_SCHEMAS`. `connector` lives in that third map. So the BFS never started from it, `integration/DataSyncConfig` — two hops away, through `connector.syncConfig` unwrapped once through `optional` — answered `null`, and a bare deletion of one of its baseline lines was waived as "an over-collected entry, never parsed against a metadata document". `stack.connectors[]` and `PUT /api/v1/meta/connector/:name` parse a real metadata document through that def on every boot. The gate's own docblock names this as the dangerous direction: a false "reachable" demands a tombstone too many, **a false "unreachable" would waive one silently**. The tree held exactly that false unreachable. ## What this does NOT do It does **not** add `connector` to `listMetadataTypeSchemaTypes()`. Two different questions wear the same words, and the gate conflated them: - **"is this a REGISTERED metadata type?"** — what `listMetadataTypeSchemaTypes()` answers, correctly. Enrolling the unregistered kinds there would grant a status #6245 deliberately withheld (enum member, registry entry, create seed, a place in the #4001 campaign count), and #2657's B/C decision stays open and unprejudged. - **"is there an AUTHOR who could be authoring against this def?"** — the only question a *reachability* root set asks, because the sole consequence of `null` is waiving a tombstone on the grounds that nobody can receive the prescription. So the gate now enumerates its own reachability root union — `reachabilityRootTypes()`, reading `listUnregisteredKindSchemaTypes()`, which exists (#6931) so a check can ENUMERATE that map and for nothing else, and whose listing grants nothing. `listMetadataTypeSchemaTypes()` is unchanged, and `packages/spec/src/kernel/metadata-type-schemas.ts` is untouched by this PR. ## Measured, on `c548dea2` **The waiver, both ways.** The card's literal repro key (`integration/DataSyncConfig:schedule`) already landed on `main` with the #16320 retirement, so the demonstration deletes a live sibling key under the same def — `timestampField`, removed from `DataSyncConfigSchema` and from `authorable-surface/integration.json`, no tombstone, no registry entry. The gate judges the DEF half of the key (`key.slice(0, key.indexOf(':'))`), so this is the identical code path. | run | `check:authorable-surface` | `gen:schema` | verdict printed | |:---|:---|:---|:---| | before (`ca78860`) | exit **0** | exit **0** | `integration/DataSyncConfig:timestampField — def not reachable from the 26 metadata-type roots` | | after | exit **1** | exit **1** | `integration/DataSyncConfig:timestampField — def reachable from the metadata-type roots; the entry at ca78860 was LIVE (never tombstoned).` | Both legs restored from `HEAD` and proven byte-identical with `git hash-object` against the `HEAD` blob, with `git diff HEAD` empty. **Root-set delta.** 26 roots to 30; the four added are exactly `analytics_cube`, `connector`, `sharing_rule`, `webhook`; none dropped. **Closure delta (the positive control, and more than the card scoped).** Over the 1523 emitted defs, comparing the verdict map computed from the old root set against the new one: - **0** defs went from reachable to `null` — a root set that grows never shrinks a closure. `ObjectSchema` (`data/Object`) stays `root-graph`. - **17** defs stop being waivable (`null` verdicts 1035 to 1018): 16 to `root-graph` and `shared/FieldMapping` to `derived-clone`. So the answer to "is `integration/DataSyncConfig` the whole of the gap" is **no** — it is 1 of 17. The other 16 are the connector / sharing-rule / analytics-cube families: `data/CubeJoin`, `data/Dimension`, `data/Metric`, `integration/CircuitBreakerConfig`, `integration/ConnectorAction`, `integration/ConnectorFieldMapping`, `integration/ConnectorHealth`, `integration/ConnectorInstanceAuth` and its four auth branches, `integration/ConnectorTrigger`, `integration/HealthCheckConfig`, `integration/RetryConfig`. - **7** more sharpen from `derived-clone` to `root-graph` (`automation/Webhook`, `data/Cube`, `integration/Connector`, `integration/DeclarativeConnectorEntry`, `integration/WebhookConfig`, `security/CriteriaSharingRule`, `security/SharingRule`) — already refused before, refused now, with a truer reason printed. Nothing in the committed baselines moves: the waiver only fires on a deletion, so `check:authorable-surface` on the pristine tree is green before and after, and `pnpm --filter @objectstack/spec build` leaves the working tree clean. **Ablation.** With `reachabilityRootTypes()` swapped back for `listMetadataTypeSchemaTypes()` at the one call site — mutation proven on disk by anchor counts (1 to 0 and 0 to 1 on the two spellings) before the run, and the gate is executed by `tsx` from source with no `dist` in between — the new pin goes red on `expect(status).toBe(1)` receiving `0`: the deletion is waived again. Restored from `HEAD`, hash-verified. ## The pin `packages/spec/scripts/build-schemas-check-mode.test.ts` gets one case reading **both directions off one seeded state**, because either alone is satisfiable by a gate that is simply wrong in the other: "always reachable" passes the first assertion and destroys proof 2; "always unreachable" passes the second and restores the defect. It is read **twice**. Once with `OS_EAGER_SCHEMAS=1` — the way `gen:schema` and `check:authorable-surface` actually run, and the only regime where `reachableVia()` can answer `root-graph` at all; without the flag `lazySchema()` hands back a Proxy, `zodByDefKey` holds the Proxy while the walk visits the resolved target, and the same verdict arrives through the derived-clone bridge. The verdict is what the gate acts on, so the verdict is pinned in both regimes and the wording only in the eager one. The `beforeAll` fixture guard asserts `connector` is still absent from `listMetadataTypeSchemaTypes()` and still present in `listUnregisteredKindSchemaTypes()` — acceptance 4 stated where it fails rather than where it is believed, and the thing that keeps this pin discriminating: reverse #6245 and the case would pass while asserting nothing. ## Acceptance notes Observations from the surrounding code, noted and deliberately not acted on here: - `packages/spec/scripts/liveness/check-liveness.mts` builds its governance denominator from `listMetadataTypeSchemaTypes()` too, under the comment "i.e. exactly the set of authorable metadata types" — the same sentence #17356 falsified for the reachability gate. `webhook` is patched in by hand (an `EXTRA_SCHEMAS` row plus `liveness/webhook.json`); `connector`, `sharing_rule` and `analytics_cube` are in neither `GOVERNED` nor `PENDING_GOVERNANCE`, so `report.ungoverned` cannot name them — they are not in its denominator. Filed separately rather than fixed here: whether those three should carry liveness ledgers is the governance question #2657 leaves open, not a root-enumeration bug. - `packages/spec/src/ui/door-reachability.testkit.ts` runs the same enumeration and is **not** blind, because it pushes `ObjectStackSchema` as an extra root and reaches `connector` through `stack.connectors[]`. Corroborating, and the reason the fix here reads the kind map rather than adding the stack root: the kind map is the set of authoring doors, the stack schema is one of them. - The docblock on `listUnregisteredKindSchemaTypes()` still counts five kinds ("webhook / connector / sharing_rule / theme / analytics_cube"); the map holds four since #10485 retired `theme`. One line of prose in a file this PR's declared surface marks read-only, so it is left alone rather than folded in. ## Scope Two files, both under `packages/spec/scripts/`. Neither is in the package's `files[]`, so nothing published moves — measured: `reachabilityRootTypes` has 0 hits across `dist`, `json-schema`, `liveness`, `prompts`, `api-surface`, `llms.txt`, `spec-changes.json`, `README.md` and `src/**/*.zod.ts`, against a positive control (`listUnregisteredKindSchemaTypes`) that has 7. Hence `skip-changeset`. --- ## The ruling this PR executes, and the shape it finally took Card #17356's options went to the maintainer twice. The governing ruling is the second one, comment `5696910871` — **甲**: > Letter **A** lands FIRST, in its own anchor-only PR. Then PR #18131 merges main. > **B** is refused: for `integration/DataSyncConfig:schedule` it would reverse the maintainer's 2026-09-10 retirement ruling recorded in the source. > **C** is filed: **#18301** — check (c) gains a fourth proof so a guidance-route retirement on a reachable def proves itself; `data/Metric:filters` is its specimen. > **D** is refused. That supersedes the earlier batch #135 reply (「135 同意」 on 「A 本 PR 内独立提交 + C 另立卡」), which had letter A as a second commit inside this PR. This body described that older shape until the merge landed; it is corrected here rather than left to become the squash commit message. **What actually happened, in order:** 1. **Letter A landed alone**, as PR #18485 — one file, `packages/spec/authorable-surface.base.json`, +1024/-1056 — merged 2026-09-16T16:37:10Z as squash `fed4a15ab5`. The anchor advanced to `baseRev 85c6d76…`, 7804 keys. 2. **This PR then merged main** through `scripts/pm/os-regen-merge.sh` — never a bare `git merge`, because `.gitattributes:143` routes the anchor to `merge=os-regen`, a driver that exits 0 with no conflict markers while dropping one side. Merge commit `59c50319` (parents `dc98ee22a3` + `fed4a15ab5`), then the wrapper's step-3 commit `251d76a2`, which takes main's side of the anchor and changes nothing else. 3. **Letter C (#18301) is untouched here** — no fourth proof is added to check (c) — and the #16320 retirement of `integration/DataSyncConfig:schedule` is not reversed: the key is absent from the emitted surface and from the anchor. **The anchor is no longer part of this PR.** At head `251d76a2` the committed anchor is byte-identical to `origin/main`'s (`cmp` exit 0 at both `8cf527f8` and `fb6b2c36`), and `authorable-surface.base.json` does not appear in this PR's diff at all. The earlier branch-local advance to `b9598e9cab9d` (7772 keys) is history inside commit `dc98ee22a3`; the head carries main's `85c6d76e` (7804 keys). Anything this body said about "7772 vs 7772" belonged to that superseded shape. **The diff at head is exactly the two scripts** — `build-schemas.ts` +56/-4 and `build-schemas-check-mode.test.ts` +136/-2, 192+/6- — byte-identical to the original implementation diff `ceb66899^..c548dea`, with main having touched neither file between the original base and `fed4a15ab5`, nor since. Nothing was swallowed by the merge in either direction. **The seven reds are gone.** `Build Core`, `Dogfood Verify CLI`, `Dogfood Regression Gate` and its three shards, and `Temporal Conformance (live PG + MySQL)` were all `failure` on the pre-merge head `dc98ee22a3`, every one carrying the same annotation — `command (…/packages/spec) …/pnpm run build exited (1)` — from one root cause: this PR's own widened root set meeting a **stale committed anchor**, under which two keys retired on main after `53ef05744f37` surfaced as deletions this branch appeared to make. Advancing the anchor on main is what removed the premise. On head `251d76a2`, latest run per check NAME: **31 success, 4 skipped** (`Build Docs`, `Check Changeset`, `Console Pin Gate`, `Packed-tarball smoke (opt-in)` — no verdict, not green), 0 failure, 0 in progress. **`skip-changeset`, re-measured at head.** The two diff paths are under `packages/spec/scripts/` and are matched by none of the package's ten `files[]` entries; the anchor is not in the diff; `gen:schema` and `check:authorable-surface` on the pristine head both exit 0 and leave `git status --porcelain` empty. Nothing published moves, so no changeset is owed. **Contract review.** At-tier review on this exact head returned **PASS**; the record and the seat's adjudication of its escalated flags are in the PR thread. The `Clause-②: yes` declaration above is the conservative route into that review, not a claim that this diff publishes bytes — the review measured the bytes and found none. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97233b9 commit 2882528

2 files changed

Lines changed: 192 additions & 6 deletions

File tree

‎packages/spec/scripts/build-schemas-check-mode.test.ts‎

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ import {
6262
RETIRED_DEFS_BY_MAJOR,
6363
RETIRED_KEYS_BY_MAJOR,
6464
} from '../src/migrations/registry';
65+
// Read ONLY to keep the #17356 fixture honest about which SET its root lives in
66+
// — never to assert gate behaviour, which is read off the spawned run's output.
67+
import {
68+
listMetadataTypeSchemaTypes,
69+
listUnregisteredKindSchemaTypes,
70+
} from '../src/kernel/metadata-type-schemas';
6571
import {
6672
AUTHORABLE_SURFACE_DIR_NAME,
6773
SCHEMA_MANIFEST_DIR_NAME,
@@ -496,7 +502,7 @@ afterAll(() => {
496502
if (sharedSandbox) fs.rmSync(sandboxRoot(sharedSandbox), { recursive: true, force: true });
497503
});
498504

499-
function run(args: string[] = []): { status: number; output: string } {
505+
function run(args: string[] = [], extraEnv: NodeJS.ProcessEnv = {}): { status: number; output: string } {
500506
const r = spawnSync(TSX, [script, ...args], {
501507
cwd: sandbox,
502508
encoding: 'utf8',
@@ -505,11 +511,29 @@ function run(args: string[] = []): { status: number; output: string } {
505511
// The generator shells out to git itself (`merge-base`, `cat-file`, a
506512
// `--depth=1` fetch), so the fixture's isolation has to reach its children
507513
// too — a `GIT_DIR` inherited here would point them at another repo (#9068).
508-
env: HERMETIC_ENV,
514+
env: { ...HERMETIC_ENV, ...extraEnv },
509515
});
510516
return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}` };
511517
}
512518

519+
/**
520+
* How `gen:schema` and `check:authorable-surface` actually run — both package
521+
* scripts export `OS_EAGER_SCHEMAS=1`, so `lazySchema()` returns the real schema
522+
* and every def key holds the instance the BFS walks.
523+
*
524+
* Left OFF by default, because it is: this file's other cases pin the gate's
525+
* reporting and its side effects, which the flag does not touch, and turning it
526+
* on for all of them would change a graph shape they were written against. But a
527+
* REACHABILITY case cannot be indifferent to it. Without the flag `lazySchema()`
528+
* hands back a Proxy, `zodByDefKey` holds the Proxy while the walk visits the
529+
* resolved target, and a def that IS a root's own child resolves through the
530+
* derived-clone bridge instead of by identity — 'reachable' either way, so the
531+
* gate's verdict is the same, but it is not the closure CI computes and
532+
* `reachableVia()` never answers 'root-graph'. #17356's acceptance is stated in
533+
* that vocabulary, so the pin below reads both.
534+
*/
535+
const EAGER_SCHEMAS_ENV: NodeJS.ProcessEnv = { OS_EAGER_SCHEMAS: '1' };
536+
513537
/** Seed the sandbox manifest shards from the committed set; returns the bytes. */
514538
function seedManifest(mutate: (schemas: string[]) => string[]): string {
515539
return writeManifestShards(manifestDir, mutate([...pristine]));
@@ -957,6 +981,29 @@ const DELETED_LEAF_COLLIDER = `data/Object:${DELETED_LEAF_COLLIDER_LEAF} [RETIRE
957981
* envelope no metadata document is ever parsed against (the issue's own
958982
* over-collection example). */
959983
const DELETED_UNREACHABLE = 'api/SessionResponse:zzOverCollected4650';
984+
/** #17356's pin. A def whose ONLY root is an UNREGISTERED KIND — `connector`,
985+
* bound in `UNREGISTERED_KIND_SCHEMAS` by #6245 and deliberately absent from
986+
* `listMetadataTypeSchemaTypes()`. `integration/DataSyncConfig` sits two hops
987+
* from that root (`connector.syncConfig`, unwrapped once through `optional`),
988+
* and `stack.connectors[]` / `PUT /api/v1/meta/connector/:name` both parse a
989+
* real metadata document through it.
990+
*
991+
* Until #17356 the gate built its roots from `listMetadataTypeSchemaTypes()`
992+
* alone, so this def read `null` and check (c) proof 2 WAIVED a bare deletion
993+
* of its baseline line as "over-collection, never parsed against a metadata
994+
* document" — the false "unreachable" the #4650 docblock names as the
995+
* dangerous direction. Measured on `main` at ca7886047b27 by deleting
996+
* `integration/DataSyncConfig:timestampField` from both the schema and the
997+
* baseline: `gen:schema` exit 0, with the proof-2 line printed.
998+
*
999+
* The prop is synthetic for the reason every fixture here is: check (c) only
1000+
* ever sees a key the build STOPPED emitting, and the def is judged by its
1001+
* DEF half (`key.slice(0, key.indexOf(':'))`), so a synthetic leaf under the
1002+
* real def runs the identical code path as the real deletion did. */
1003+
const DELETED_VIA_UNREGISTERED_KIND_DEF = 'integration/DataSyncConfig';
1004+
const DELETED_VIA_UNREGISTERED_KIND = `${DELETED_VIA_UNREGISTERED_KIND_DEF}:zzOnlyRootIsAnUnregisteredKind17356`;
1005+
/** The unregistered kind that def's only root lives in. */
1006+
const UNREGISTERED_KIND_ROOT = 'connector';
9601007
/** Def the build no longer emits at all — the literal #4643 cluster. */
9611008
const DELETED_GONE_DEF = ['identity/Session:userId', 'identity/Session:token'];
9621009
/** Aged-out tombstone. Since #5898 the proof is a DECLARATION, not a clause
@@ -990,6 +1037,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
9901037
DELETED_UNREGISTERED,
9911038
DELETED_LEAF_COLLIDER,
9921039
DELETED_UNREACHABLE,
1040+
DELETED_VIA_UNREGISTERED_KIND,
9931041
...DELETED_GONE_DEF,
9941042
DELETED_AGED,
9951043
DELETED_BY_RENAME,
@@ -1023,6 +1071,26 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
10231071
Object.values(RETIRED_KEYS_BY_MAJOR).flat(),
10241072
`${DELETED_LEAF_COLLIDER} is now declared for real — the pin needs an UNdeclared key`,
10251073
).not.toContain(DELETED_LEAF_COLLIDER.replace(RETIRED_MARK, ''));
1074+
// #17356's fixture is only a pin while its def's root is still an
1075+
// UNREGISTERED kind. Both halves are loud here: enrol `connector` into the
1076+
// registered set (reversing #6245) and the test below still passes while
1077+
// asserting nothing about this gate's own root union — the exact way a pin
1078+
// goes quiet. This pair is also acceptance 4 of the card, stated where it
1079+
// fails rather than where it is believed.
1080+
expect(
1081+
listMetadataTypeSchemaTypes(),
1082+
`'${UNREGISTERED_KIND_ROOT}' is now a REGISTERED metadata type — #6245's boundary moved, ` +
1083+
`so the #17356 fixture no longer models a def rooted only in UNREGISTERED_KIND_SCHEMAS`,
1084+
).not.toContain(UNREGISTERED_KIND_ROOT);
1085+
expect(
1086+
listUnregisteredKindSchemaTypes(),
1087+
`'${UNREGISTERED_KIND_ROOT}' left UNREGISTERED_KIND_SCHEMAS — re-pick the fixture's root`,
1088+
).toContain(UNREGISTERED_KIND_ROOT);
1089+
expect(
1090+
keys.some((k) => k.startsWith(`${DELETED_VIA_UNREGISTERED_KIND_DEF}:`)),
1091+
`${DELETED_VIA_UNREGISTERED_KIND_DEF} is no longer emitted with authorable keys — check (c) ` +
1092+
`would route this fixture to the vanished-def proof instead; re-pick the def`,
1093+
).toBe(true);
10261094
// The manifest ratchet runs first; keep it current so every run reaches (c).
10271095
seedManifest((s) => s);
10281096
});
@@ -1138,6 +1206,72 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
11381206
},
11391207
);
11401208

1209+
it(
1210+
'#17356 — a def rooted only in an UNREGISTERED kind is reachable, and a genuinely unreachable one still is not',
1211+
{ timeout: SPAWN_TIMEOUT_MS },
1212+
() => {
1213+
// BOTH directions in ONE run, because either alone is satisfiable by a
1214+
// gate that is simply wrong in the other direction: "always reachable"
1215+
// passes the first assertion and destroys proof 2, "always unreachable"
1216+
// passes the second and restores the defect.
1217+
//
1218+
// The defect: `computeSurfaceReachability()` built its roots from
1219+
// `listMetadataTypeSchemaTypes()`, which per #6245 deliberately does not
1220+
// enumerate `UNREGISTERED_KIND_SCHEMAS`. `connector` lives there, so the
1221+
// BFS never started from it, `integration/DataSyncConfig` read `null`,
1222+
// and a bare deletion of one of its baseline lines was waived as
1223+
// over-collection — for a def `stack.connectors[]` parses on every boot.
1224+
// The gate now enumerates its own reachability root union; the KIND
1225+
// vocabulary `listMetadataTypeSchemaTypes()` answers is untouched (the
1226+
// `beforeAll` guard above asserts that half).
1227+
seedBase((s) => [...s, DELETED_VIA_UNREGISTERED_KIND, DELETED_UNREACHABLE].sort());
1228+
const canonical = seedSurface((s) => s);
1229+
1230+
const rx = (key: string, tail: string): RegExp =>
1231+
new RegExp(`${key.replace(/[/$]/g, '\\$&')} — ${tail}`);
1232+
1233+
// Read the gate as CI runs it FIRST — `gen:schema` exports
1234+
// OS_EAGER_SCHEMAS=1, and only there does the card's acceptance sentence
1235+
// ("answers a root-graph hit rather than null") have a literal reading.
1236+
const eager = run(['--check'], EAGER_SCHEMAS_ENV);
1237+
1238+
// Direction 1 — the unregistered-kind root is a root: no waiver, and the
1239+
// verdict names the reason a reader has to act on (the entry was LIVE).
1240+
expect(eager.status).toBe(1);
1241+
expect(eager.output).toContain('authorable baseline line(s) were deleted without proof (#4650)');
1242+
expect(eager.output).toMatch(
1243+
rx(DELETED_VIA_UNREGISTERED_KIND, 'def reachable from the metadata-type roots; .*was LIVE'),
1244+
);
1245+
// Specifically NOT the proof-2 waiver, for this key. Asserting the absence
1246+
// is the pin: narrow the roots back to `listMetadataTypeSchemaTypes()` and
1247+
// the run exits 0 printing exactly the string below.
1248+
expect(eager.output).not.toMatch(rx(DELETED_VIA_UNREGISTERED_KIND, 'def not reachable from the'));
1249+
1250+
// Direction 2 — conservatism is not turned around. A REST response
1251+
// envelope no metadata document is parsed against still reads unreachable
1252+
// and still carries its own proof, in this same run.
1253+
expect(eager.output).toContain('carry their own proof (#4650)');
1254+
expect(eager.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots'));
1255+
// The waiver message names all three sources of the union it computed, so
1256+
// a reader judging a waiver is not reading the pre-#17356 claim that the
1257+
// roots are the REGISTERED set.
1258+
expect(eager.output).toContain('BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS');
1259+
expect(eager.output).toContain('UNREGISTERED_KIND_SCHEMAS');
1260+
1261+
// Same two directions under the lazy-Proxy graph, where the def resolves
1262+
// through the derived-clone bridge rather than by identity. The VERDICT is
1263+
// what this gate acts on, so it is the verdict that is pinned in both
1264+
// regimes; the wording differs and is deliberately not asserted here.
1265+
const lazy = run(['--check']);
1266+
expect(lazy.status).toBe(1);
1267+
expect(lazy.output).toMatch(rx(DELETED_VIA_UNREGISTERED_KIND, 'def .*was LIVE \\(never tombstoned\\)'));
1268+
expect(lazy.output).not.toMatch(rx(DELETED_VIA_UNREGISTERED_KIND, 'def not reachable from the'));
1269+
expect(lazy.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots'));
1270+
1271+
expect(readSurface()).toBe(canonical);
1272+
},
1273+
);
1274+
11411275
it(
11421276
'check (a) is intact: a key the BUILD stops emitting while still recorded is fatal before (c) ever runs',
11431277
{ timeout: SPAWN_TIMEOUT_MS },

‎packages/spec/scripts/build-schemas.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import { RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../src/migrations/
8585
import {
8686
getMetadataTypeSchema,
8787
listMetadataTypeSchemaTypes,
88+
listUnregisteredKindSchemaTypes,
8889
} from '../src/kernel/metadata-type-schemas';
8990
import * as AI from '../src/ai';
9091
import * as API from '../src/api';
@@ -1142,11 +1143,61 @@ interface SurfaceReachability {
11421143
reachableVia(defKey: string): 'root-graph' | 'derived-clone' | null;
11431144
}
11441145

1146+
/**
1147+
* The type names this gate starts its BFS from — deliberately NOT the same set
1148+
* as `listMetadataTypeSchemaTypes()`, and the difference is the whole of #17356.
1149+
*
1150+
* Two different questions wear the same words here, and conflating them produced
1151+
* a false "unreachable" in the tree:
1152+
*
1153+
* - **"is this a REGISTERED metadata type?"** — `listMetadataTypeSchemaTypes()`,
1154+
* which unions BUILTIN_METADATA_TYPE_SCHEMAS with the
1155+
* EXTRA_METADATA_TYPE_SCHEMAS overlay and, per **#6245**, pointedly does not
1156+
* enumerate UNREGISTERED_KIND_SCHEMAS: enrolling those entries there "would
1157+
* claim a status this change is careful not to grant" (a `MetadataTypeSchema`
1158+
* enum member, a DEFAULT_METADATA_TYPE_REGISTRY entry, a create seed, a place
1159+
* in the #4001 campaign count). That function answers its own question
1160+
* correctly and this file does not touch it.
1161+
* - **"is there an AUTHOR who could be authoring against this def?"** — the only
1162+
* question a REACHABILITY root set is asking, because the sole consequence of
1163+
* `reachableVia() === null` is waiving a tombstone on the grounds that nobody
1164+
* can receive the prescription. For THAT question the unregistered kinds are
1165+
* authored documents too: `PUT /api/v1/meta/connector/:name` and a
1166+
* `defineStack({ connectors: [...] })` manifest both parse a metadata document
1167+
* against `UNREGISTERED_KIND_SCHEMAS['connector']` (#6245 bound them there for
1168+
* exactly that reason), and `getMetadataTypeSchema()` resolves them as its
1169+
* third fallback.
1170+
*
1171+
* Measured on #17356: with the registered set alone the BFS starts from 26 roots,
1172+
* closes over 5420 nodes, and misses `integration/DataSyncConfig` — two hops from
1173+
* the `connector` root, through `syncConfig` unwrapped once through `optional` to
1174+
* the very instance the module exports. A bare deletion of one of its baseline
1175+
* lines was therefore waived by check (c) proof 2 as "over-collection, never parsed
1176+
* against a metadata document", while `stack.connectors[]` parses it on every boot.
1177+
*
1178+
* ⛔ So do NOT "simplify" these two back into one call. They differ on purpose, in
1179+
* the direction #6245 fixed and the direction #4650's docblock promises: one shared
1180+
* entry marks a def reachable, because a false "reachable" demands a tombstone too
1181+
* many while a false "unreachable" would waive one silently.
1182+
*
1183+
* `listUnregisteredKindSchemaTypes()` exists (#6931) so a check can ENUMERATE that
1184+
* map and for nothing else, and being listed by it grants nothing — which is the
1185+
* whole reason it, and not a new kind registration, is what this gate reads.
1186+
*/
1187+
function reachabilityRootTypes(): string[] {
1188+
const types = new Set<string>(listMetadataTypeSchemaTypes());
1189+
for (const kind of listUnregisteredKindSchemaTypes()) types.add(kind);
1190+
return [...types].sort();
1191+
}
1192+
11451193
/**
11461194
* Reachability of every emitted def from the metadata-type roots —
11471195
* BUILTIN_METADATA_TYPE_SCHEMAS plus the EXTRA_METADATA_TYPE_SCHEMAS overlay
1148-
* (both behind listMetadataTypeSchemaTypes / getMetadataTypeSchema), i.e. the
1149-
* schemas a metadata document is actually parsed against. Computed by BFS over
1196+
* plus the UNREGISTERED_KIND_SCHEMAS bindings (all three behind
1197+
* `reachabilityRootTypes()` / getMetadataTypeSchema), i.e. the schemas a metadata
1198+
* document is actually parsed against. That union is this gate's own, and the
1199+
* docblock on `reachabilityRootTypes()` above is the authority on why it is not
1200+
* `listMetadataTypeSchemaTypes()`. Computed by BFS over
11501201
* THIS build's in-memory Zod graph, per the 2026-08-02 ruling on #4650 — a
11511202
* static import/regex approximation misses alias imports, runtime
11521203
* registration and casts, so it is deliberately not used here.
@@ -1163,7 +1214,7 @@ interface SurfaceReachability {
11631214
function computeSurfaceReachability(): SurfaceReachability {
11641215
const rootTypes: string[] = [];
11651216
const roots: z.ZodType[] = [];
1166-
for (const type of listMetadataTypeSchemaTypes()) {
1217+
for (const type of reachabilityRootTypes()) {
11671218
const schema = getMetadataTypeSchema(type);
11681219
if (schema) {
11691220
rootTypes.push(type);
@@ -2202,7 +2253,8 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null;
22022253
if (via === null) {
22032254
allowed.push(
22042255
`${key} — def not reachable from the ${reachability.rootTypes.length} metadata-type roots\n` +
2205-
` (BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS overlay; BFS over this\n` +
2256+
` (BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS overlay +\n` +
2257+
` UNREGISTERED_KIND_SCHEMAS, this gate's own union — #17356; BFS over this\n` +
22062258
` build's in-memory Zod graph): an over-collected entry, never parsed against a\n` +
22072259
` metadata document. This waives ONLY the tombstone requirement of this file — it is\n` +
22082260
` not a license to change the schema (#4650).`,

0 commit comments

Comments
 (0)