fix(spec): the authorable-surface reachability roots include the unregistered kind schemas - #18131
Conversation
`computeSurfaceReachability()` in the authorable-surface deletion gate built its root set from `listMetadataTypeSchemaTypes()`, which per #6245 deliberately does not enumerate `UNREGISTERED_KIND_SCHEMAS`. `connector` lives there, so the BFS never started from it and `integration/DataSyncConfig` — two hops away through `syncConfig` — read `null`: check (c) proof 2 waived a bare baseline deletion for a def `stack.connectors[]` and `PUT /api/v1/meta/connector/:name` really parse. The gate now enumerates its own reachability root union. #6245's guarantee is untouched: `listMetadataTypeSchemaTypes()` still does not name the unregistered kinds, and being read here grants none of the KIND obligations it withholds. Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g Co-authored-by: Claude <noreply@anthropic.com>
…d roots A def whose only root is an UNREGISTERED kind (`integration/DataSyncConfig`, two hops from `connector`) must be refused a bare baseline deletion, while a genuinely unreachable def (`api/SessionResponse`) must still carry proof 2 — both read off ONE seeded state, because either direction alone is satisfiable by a gate that is simply wrong in the other. Read twice: once with OS_EAGER_SCHEMAS=1, the way `gen:schema` runs and the only regime where `reachableVia()` can answer 'root-graph'; once without, where `lazySchema()` hands back a Proxy and the same verdict arrives through the derived-clone bridge. The fixture guard asserts #6245's boundary has not moved, which is what keeps the pin discriminating. Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
PM reading on the red CI — ⭐ this failure is the fix working, not the fix breaking
Failing: It is this PR's failure, not the base branch'sChecked first, as the rules require: on But this PR deletes no baseline lineIts whole diff is two files: ⛔ Nothing under
⇒ both lines left the tracked surface at some earlier commit, and the broken gate waived them under check (c) proof 2 — "not reachable from the metadata-type roots". That is exactly the harm this card exists to end, and it already happened. Widening the roots removes the waiver, so the build now refuses what it used to wave through. The red is the acceptance criterion firing.
|
…achability-root-enumeration
|
Pointer for the re-dispatch (director seat, 2026-09-15T15:05Z): #17356 is ruled — batch #135 item 3, maintainer 「135 同意」 on A + C. For this PR: a second, separate commit advancing the committed anchor ( Generated by Claude Code |
…achability-root-enumeration
The maintainer's ruling on batch #135 item 3 (letter A) directs this PR to carry a second, separate commit that advances the committed anchor with `pnpm --filter @objectstack/spec gen:authorable-surface-base`. `authorable-surface.base.json` was anchored on 53ef057 (7836 keys) and is now anchored on b9598e9 (7772 keys), the merge base this branch resolves against origin/main after the preceding merge commit. Why it is owed: this branch widens the reachability root set from 26 to 30, so two keys that main had already retired through the guidance route stopped being waivable as "def not reachable from the metadata-type roots". Against the stale anchor a shallow CI checkout reads them as deletions this branch made: 2 authorable baseline line(s) were deleted without proof (#4650): - data/Metric:filters - integration/DataSyncConfig:schedule Both retirements are already on main. Advancing the anchor is the deliberate maintenance act the build's own output names; it forgives no future deletion, and a bare deletion of a reachable key is still refused on the advanced anchor. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…achability-root-enumeration
Contract reviewServed-tier: ① Derived judgmentsDiff at the head, against merge base
② Semver levelRule read from
Measured level: no changeset owed — nothing publishes. The two diff paths are under One contradiction the seat must own (detailed in ③, flag 6): the PR body and the card claim both declare ③ Boundary flags
Implemented-by: VERDICT: PASS Seat provenance and adjudication (PM note, not part of the record)
The five escalated flags, adjudicated:
Seat correction carried from the merge round: the dispatch for that round asserted Generated by Claude Code |
…tack-ai#18243) Fixes objectstack-ai#18229 ## What changed `scripts/pm/check-half-states.mjs` only. H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` — the wide body-first/branch-fallback delivery relation — as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped, so acting on it hangs the gate on a card nothing will ever close — where the stroke that clears gates never arrives. Three edits: 1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read of `deliveryEvidence`'s existing grading, never a second keyword parser. It answers "does this PR CLOSE #n", which is the question the gate's clearing stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer false. 2. **H31's own population filter** — the carrier comparison runs over the PRs `bindingClosesCard` accepts. Those rows are byte-identical to before. 3. **A DECLINED row for every other binding** — a weak-bound delivering PR whose carrier differs from the card's still produces a row, one that names both carriers and the binding it read, states that it declined to judge, and prescribes no write. A weak-bound PR whose carrier agrees produces nothing, exactly as before. `TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one spelling across H13's exemption list and H31's new clause. ## Why this seam, and how the shared invariant survives The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids it (「⛔ Do not narrow it here to serve H8: that would make the live half invisible to the rows that exist to see it」), and H8's open side, H35's sibling resolver, `claimDelivery`, H53 and the `check-clause2-carriers` pairing all still read it wide. The narrowing is H31's own filter over the population that relation hands it, so the rows still agree about which PR delivers which card and differ only about which binding makes a CARRIER PAIR — a question only H31 asks. Both docblocks that state the invariant were updated to say so rather than left to imply the old thing. Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's own uniform: a split that was never judged renders identically to a board whose two carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」. Hence the declined row, and hence its scope: it speaks only where the carriers actually differ, because a standing row per tracker per sweep is the disease, not the cure. The declined row is deliberately NOT marked `UNJUDGED_MARKER` — that marker buys trim priority ahead of judged rows, and a decline must never sort ahead of a real carrier split in the same `gate` band. ## Measured on the live board Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run 34919049964, carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212 (draft, via a `Part of` declaration). Replayed offline against the 9 open PRs and the 9 open cards those PRs name (the exact H31 candidate set — a card no open PR names can never reach the comparison): | card | `tracking` | delivering evidence | row before | row after | |---|---|---|---|---| | objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none | | objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none | | objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none | | objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none | | objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none | | objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding | **DECLINED row** | | objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none | | objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none | | objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none | One row changes, and it is the expected one. Every other delivering binding on the live board is a closing keyword, so nothing else moves. ## Tests `pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs --self-test`): **4042 cases pass** before, **4075 cases pass** after, exit 0 — 33 new cases. The pin and the control both land, per the card's own remedies: - pin: the `Part of`-only tracker still produces a row (⛔ not a silent skip), the row says `DECLINES to judge`, names the binding it read, explains why a ruling anchor can never clear a gate, and prescribes NOTHING; it is asserted to be neither the old action-shaped sentence nor a LOUD row nor an UNJUDGED-ranked one. - control: a closing-keyword binding whose card lacks the gate — the row STANDS, with the unchanged action-shaped sentence. - plus: agreement on a weak binding stays clean both ways; a weak-bound card without `tracking` still reports the split but without the ruling-anchor clause; an adjudicable split outranks a decline and names only the closing-bound PR. **Ablation** (one-off, restored; run from the committed fix). Mutating `bindingClosesCard` back to the pre-fix wide population (`deliveryEvidence(pr, n) !== null`) turns the self-test RED: ``` == anchor counts BEFORE == old-text occurrences: 1 / new-text occurrences: 0 mutation written == anchor counts AFTER == old-text occurrences: 0 / new-text occurrences: 1 HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 MUT blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75 VERDICT ablation-exit=1 ✗ check-half-states self-test: 11 of 4075 case(s) failed. == restore leg == REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 old-text occurrences after restore: 1 / new-text occurrences after restore: 0 git diff HEAD bytes: 0 ``` The mutation is proved on disk by the blob hash moving off the HEAD blob and by the two anchor occurrence counts flipping; the restore leg is proved by the blob hash returning to the HEAD blob and by an empty `git diff HEAD`, not by an exit code. There is no build step and no `dist/` for a repo-root `.mjs`, so the rebuild half of the ablation preflight does not apply here. **Gates** — derived with no paths, at head `e0a4efa`: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 41 command(s) node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 41 exited 0, each captured by redirect before any pipe. The gate script's own wiring is inside that set: `pnpm check:pm-half-states` is the package.json step lint.yml runs, and it is the script's `--self-test`. **Lint** — a declared narrowing, not the farm. `eslint scripts/pm/check-half-states.mjs --no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0 errors, 0 warnings**, exit 0. The population is read from eslint's own config, not guessed — an `ESLint` instance over `git ls-files` reports `tracked=8674 in-scope=6755 ignored=1919 no-rules=0`. Invariance: this repo runs one `eslint.config.mjs` which enables type-aware linting for no file at all (every `parserOptions` in it is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero `project`/`projectService` hits; the config says so itself at its `QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff — so no rule's verdict on a file this PR did not touch can move because of it. The repo-wide `pnpm lint` run is CI's. `skip-changeset`: `scripts/pm/**` is PM tooling and ships in no package's `files[]`, so nothing published moves. ## Acceptance notes - `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this is in-seat review. `.github/workflows/half-state-patrol.yml` and anchor objectstack-ai#9857 are untouched: the workflow runs the script from `main`, so the fix reaches the anchor at the first scheduled sweep after landing. - objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier that `scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic tracker reached through a `Part of` line. It is out of scope here and remains open. `bindingClosesCard` is exported for it: that file already imports `deliveryEvidence` and `prDeliversCard` from this one, so the fix there is an import rather than a second copy of the predicate. - noted, not filed: the sibling install of this script in `objectui` (`scripts/pm/check-half-states.mjs`) has already drifted from this copy (different md5 at `b3b43b6`), while this file's header describes the pair as copied VERBATIM. No gate can hold two repos equal, so the wording is an aspiration rather than a claim — carrier: the next dev dispatched on a cross-repo patrol card. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ain tip (objectstack-ai#17356 letter A) (objectstack-ai#18485) `Part of objectstack-ai#17356` — this PR carries **letter A only**: the anchor advance. The finding itself is fixed by PR objectstack-ai#18131, which stays untouched this round; out of scope here: objectstack-ai#17356 remains open, and objectstack-ai#18301 (letter C) is serialized behind this. ## What is in the diff Exactly one file — `packages/spec/authorable-surface.base.json`, regenerated on this branch's own merge base with: ``` pnpm --filter @objectstack/spec gen:authorable-surface-base ``` ⛔ No root-set widening. ⛔ No other file. `git diff --name-only origin/main...HEAD` prints one line. ## Why it lands here and not inside PR objectstack-ai#18131 The authorable-key deletion gate (objectstack-ai#4650) compares deletions against the baseline **at the merge base with `origin/main`** — its own error text says so, and `build-schemas.ts` `resolveSurfaceBase()` reads the baseline keys out of git at that rev, never out of the anchor file in the tree under test: > Deletions are therefore compared against the baseline at merge base 53ef057 with origin/main, which this commit cannot rewrite. So advancing the anchor *inside* PR objectstack-ai#18131 was a no-op against that gate: a commit in a PR cannot move the PR's own merge base. This PR's merge base **is `main` itself**, so once it lands, every later PR that merges `main` carries the advanced anchor in its merge base. ## Measurements (this branch, UTC) | reading | value | |:---|:---| | branch base / merge base with `origin/main` | `85c6d76ec42a879f219436eaa58bda06c68298b2` (2026-09-16T15:19:10Z) | | anchor `baseRev` before | `53ef05744f37789a3e2e2ee94325a616b706bea0` — 7836 keys | | anchor `baseRev` after | `85c6d76ec42a879f219436eaa58bda06c68298b2` — 7804 keys | | generator's own verdict line | `⚓ authorable-surface.base.json refreshed to 85c6d76 (7804 keys) — commit it, on its own.` | | `data/Metric:filters` in the new anchor | **0** | | `integration/DataSyncConfig:schedule` in the new anchor | **0** | | control — `data/Metric:` keys in the same file | **6** (non-zero, so the zero readings above discriminate) | | control — `integration/DataSyncConfig:` keys in the same file | **8** (the def is still there; only the retired key left) | | files changed | 1 | The anchor was **re-generated here, not cherry-picked** from `dc98ee22a3`: that commit's advance targeted `b9598e9cab9d`, and `main` has taken further landings since, so copying it would have pinned a stale rev. The numbers above are this branch's own, measured; they are not the ones the ruling quotes. `--update-base` was not run mid-merge: this branch is a fresh branch off `origin/main` with no merge in it, so the `objectstack-ai#5370` refusal (`build-schemas.ts` refuses `--update-base` mid-merge because `merge-base(HEAD, origin/main)` would re-anchor **backwards**) cannot apply and was not worked around. ## The rulings this executes Ruling `5682578124` (batch objectstack-ai#135 item 3, maintainer 「135 同意」, 2026-09-15): > **A** — PR objectstack-ai#18131 gains a **second, separate commit** that advances the committed anchor with `pnpm --filter @objectstack/spec gen:authorable-surface-base`, run in a **full (non-shallow) clone**; the PR body quotes this ruling (「135 同意」 on batch objectstack-ai#135 item 3, presented as 「A 本 PR 内独立提交 + C 另立卡」). Advancing the anchor is the deliberate maintenance act the build's own output names; it forgives no future deletion. > > **B** is refused: for `integration/DataSyncConfig:schedule` it would reverse the maintainer's 2026-09-10 retirement ruling recorded in the source. Ruling comment `5696910871` (class-1 self-adjudication, 2026-09-16T11:46Z), which re-carries letter A after the seat measured that the original carrier could not reach the gate: > 1. Letter A lands **first, in its own PR against `main`**, containing exactly the anchor-advance commit lifted from `dc98ee22a3` (`authorable-surface.base.json` only: `53ef05744f37` 7836 keys → `b9598e9cab9d` 7772 keys, re-generated on the PR's own merge base if it has moved). ⛔ No root-set widening, ⛔ no other file. Its PR body quotes ruling 5682578124 and this comment. > 2. Once it is on `main`, PR objectstack-ai#18131 merges `origin/main` (via `scripts/pm/os-regen-merge.sh`, never a bare merge) so its merge base carries the new anchor; the two keys stop reading as deletions; acceptance 2 of the original dispatch is then measured on objectstack-ai#18131's next green run. > 3. Letter B stays refused (the objectstack-ai#16320 2026-09-10 retirement of `integration/DataSyncConfig:schedule` is not reversed). Letter C stays objectstack-ai#18301 and is serialized behind this (same file). Letter B is not reversed by this PR: `integration/DataSyncConfig:schedule` stays retired. Letter C (objectstack-ai#18301) is not touched. ## The one premise this PR itself tests The ruling records it as unmeasured, and this PR's first CI run is the measurement: > The seat's inference — «an anchor-only PR that does not widen the root set is green, because proof 2 still waives the two keys on that PR's merge base» — is unmeasured. The split PR's first CI run is the measurement. If it is red for the same reason, that is a genuine fork: stop, post the first error line, and this card returns to the decision box — ⛔ not patched around. Read against the gate's source, the local expectation is narrower than an inference about proof 2: this branch emits the **same** authorable surface as its merge base (no schema and no root-set change), so on this PR the gate has no deletion to adjudicate at all. If CI disagrees, the first error line from the job log is reported and this is not patched around. ## Acceptance notes - `noted, not filed:` the generator prints its anchor verdict as `⚓ … — commit it, on its own`, which is the advice this PR follows; nothing in the tooling *enforces* that the anchor advance travels alone, and the enforcement gap is what made the earlier in-PR carrier look valid. No card filed — it is an observation, not a reproducible defect, a contract violation, or an authoring trap. --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ Co-authored-by: Claude <noreply@anthropic.com>
… not the registered set (objectstack-ai#18581) Fixes objectstack-ai#18133 Clause-②: no This card **tightens** governance coverage: the denominator the liveness ratchet divides by grows, three previously-unreachable types become nameable, and no accept set, public export or schema moves. The registry itself is byte-untouched. ## The defect, measured first-hand against `origin/main` `check-liveness.mts` built `report.ungoverned` from `listMetadataTypeSchemaTypes()` under a comment stating that function returns *"exactly the set of authorable metadata types"*. That sentence is the declared contract, and it is false — the same sentence objectstack-ai#17356 measured false for the reachability gate, one gate over. | reading (probe run at base `879b51270`) | result | |---|---| | `listMetadataTypeSchemaTypes()` | 26 names; `analytics_cube` / `connector` / `sharing_rule` / `webhook` absent from all four | | `listUnregisteredKindSchemaTypes()` | exactly those four | | firing control on the same probe | `view` and `flow` present in the registered set; `view` absent from the unregistered set | | `getMetadataTypeSchema(t)` for each of the four | resolves a schema (third fallback, objectstack-ai#6245) | | `GOVERNED` / `PENDING_GOVERNANCE` membership | `webhook` governed; the other three in **neither** map; `PENDING_GOVERNANCE` was `{}` | | `packages/spec/liveness/` | no `connector.json`, no `sharing_rule.json`, no `analytics_cube.json` | ⇒ a type in neither map produces no row in **any** of this gate's lists, so `ungoverned: []` read identically whether the gate had looked and found nothing or had never looked at all. That indistinguishability is the finding; the count of what it hid is deliberately not claimed. **One card correction.** The card attributes `webhook`'s manual patch to an `EXTRA_SCHEMAS` row. That identifier does not exist anywhere in this repository — the mechanism is `SPEC_ONLY_SCHEMAS` in `check-liveness.mts`, plus `liveness/webhook.json`. The claim is right, the symbol name is not. ## Why the repair is local, on the merits The dispatch flagged a possible fork into `packages/spec/src/kernel/metadata-type-schemas.ts`. It is not one, and the target file says so itself. `listUnregisteredKindSchemaTypes()` already exists there (objectstack-ai#6931) and its own docblock declares: > `[objectstack-ai#6931]` This exists so a check can ENUMERATE that map, and for nothing else. >⚠️ Being listed by this function grants NOTHING. It returns names, not schemas, not descriptors: no `MetadataTypeSchema` enum membership, no `DEFAULT_METADATA_TYPE_REGISTRY` entry, no create seed, no authorization verdict, no place in the objectstack-ai#4001 campaign count. And the precedent is already landed, in the second consumer the dispatch warned about: `reachabilityRootTypes()` in `scripts/build-schemas.ts` (objectstack-ai#17356, PR objectstack-ai#18131) is this exact union, computed **inside the consuming gate**, whose docblock states that `listMetadataTypeSchemaTypes()` "answers its own question correctly and this file does not touch it." ⇒ objectstack-ai#6245's guarantee survives byte-for-byte: `metadata-type-schemas.ts` is not in this diff. The gate that was asking the wrong question is where the question is fixed. **Not yet one shared spelling.** The card suggests a single helper read by both gates. `scripts/build-schemas.ts` is held by seat 1 (objectstack-ai#18301 / PR objectstack-ai#18529), so folding the two together is a follow-up; until then each docblock cross-references the other by name. ## What the gate now prints that it did not before Green run, before → after: ``` before: (no coverage line at all — it printed only when PENDING_GOVERNANCE was non-empty) after: governance denominator: 30 authorable type(s) — 26 registered kind(s) + 4 unregistered-kind stack collection(s) (analytics_cube, connector, sharing_rule, webhook); 27 governed, 3 awaiting a ledger (analytics_cube, connector, sharing_rule) — a worklist, not a merge gate. (+ 9 type(s) governed from OUTSIDE the denominator via SPEC_ONLY_SCHEMAS — not metadata types, so the override IS their governance; 36 governed in total.) ``` The success sentence changed too: "every **registered** type is governed or explicitly pending" is now "every **authorable** type — registered kind or unregistered-kind stack collection — is governed or explicitly pending". **Does it turn any existing check red? No, and here is the arithmetic.** The three newly visible types are recorded as declared debts in `PENDING_GOVERNANCE` with a reason and an issue number apiece — the disposition the gate's own failure text prescribes, and the one that keeps them *stated* rather than *silent*. `pnpm --filter @objectstack/spec check:liveness` exits **0** on this branch, and `state-counts.md` and the README state table are both still current (36 rows, unchanged — the ledger set did not move). Leaving them in `report.ungoverned` instead would exit 1 on every PR in the repo, which is a broken `main`, not a delivered finding. ## Reverse verification — two legs, both fired, both restored Run against the committed implementation; each leg proved its mutation reached disk by anchor count before the gate ran, and each restored via `git checkout HEAD -- PATH` under a `trap ... EXIT INT TERM`, verified by blob hash rather than by an exit code. **Leg A — the new denominator really reaches an unregistered kind.** Removed the `connector` row from `PENDING_GOVERNANCE` (anchor count 1 → 0): ``` ✗ 1 AUTHORABLE metadata type(s) governed by nothing: connector ``` exit **1**. Under the old denominator no edit to that map could have produced this row for `connector` — it would have been reported *stale* instead, because the type was not in the set at all. **Leg C — the union line is load-bearing, not decorative.** Reverted `authorableTypes()` to the registered set alone, keeping the three rows (anchor count 1 → 0, injected marker count 1): ``` ✗ 3 stale PENDING_GOVERNANCE row(s) — the debt is already paid: analytics_cube connector sharing_rule ``` exit **1**. That output is precisely the old code's whole vocabulary for these three: *"not a type I know"*, never *"a type nobody governs"*. Restore proof for both legs: on-disk blob `b3c8aded6e38ce73bd8097dba62554da19ec97ff` equals `HEAD:packages/spec/scripts/liveness/check-liveness.mts`, and `git diff HEAD --stat` for that path is empty. No permanent test file was left behind by either leg. ## Tests `scripts/liveness/check-liveness.test.ts` gains a `objectstack-ai#18133` block that holds the **gate's output answerable to the live registry** rather than to a copied list — a hard-coded expectation would pass unchanged if the gate stopped reading the registry at all, which is the regression class this is for. - a control that both sides of the union are non-empty and disjoint (without it, "the denominator omits nothing" is satisfied by a registry that enumerates nothing); - the denominator contains every `listUnregisteredKindSchemaTypes()` name, is **strictly larger** than the registered set, and equals the union exactly — this is the assertion that goes red the moment somebody simplifies the union away; - every member is governed or explicitly pending, `ungoverned` and `stalePending` both empty; - the composition line is printed on a **green** run; - objectstack-ai#6245's guarantee asserted from the gate that had the motive to break it: the unregistered kinds are still absent from `listMetadataTypeSchemaTypes()`. ## Verification Final commit `7c98551bac`; every reading below is from that tree. - `pnpm --filter @objectstack/spec exec vitest run scripts/liveness/check-liveness.test.ts` — 58 passed (was 53). - `pnpm --filter @objectstack/spec test` — **482 files passed, 1 skipped; 13776 tests passed, 1 skipped**. - `pnpm --filter @objectstack/spec typecheck` — exit 0. Both edited files are proven in a tsc program: `tsc -p tsconfig.scripts.json --listFiles` names `scripts/liveness/check-liveness.mts` and `scripts/liveness/check-liveness.test.ts`. - `pnpm --filter @objectstack/spec check:liveness` — exit 0. `check:empty-state` — exit 0. - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` off the merge base (never a hand-fed path list), reconciled with `--ran`: **68 derived, 63 run green, 5 NOT MEASURED, 0 UNRUN**. Every exit code was landed to disk before being read. - The 5 NOT MEASURED all exit **3** — `PREREQUISITE NOT MET`, each refusing because no package has a `dist/`: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt`. They need a repo-wide build; this diff contains no built-output source, and CI covers them. ⛔ Neither pass nor finding. - `pnpm check:cross-package-test-inputs` exits **0** here — consistent with the known `packages/spec`-is-built behaviour already filed as objectstack-ai#18353 / objectstack-ai#18440; nothing new is filed for it. - **Lint, narrowed and the narrowing proven.** `eslint --no-inline-config --format json` over the two changed TS files: **2 files linted, 0 errors, 0 warnings** (count read from the JSON, not from prose). Population read from `eslint.config.mjs` itself: the global block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, so the two `.md` paths in this diff are outside eslint's population entirely. Invariance: that config declares in its own words that this repo "runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file" — so nothing in this diff can move the verdict on a file it does not touch. The repo-wide sweep is CI's. - Control-character scan beyond `check:nul-bytes`: `grep -naP` over all four changed files — no matches. ## Declared deviation — one file outside the dispatched surface The dispatched surface was `check-liveness.mts` · its test · any `liveness/*.json` made owed · `.changeset/*.md`. This diff also edits **`packages/spec/liveness/README.md`**, and that is a deliberate, declared addition rather than an oversight: 1. that README carries a **second copy of the same false sentence** — "i.e. exactly the set of *authorable* metadata types" — so repairing only the script would have left the card's own warning realised: the sentence reappearing at a third door; 2. more decisively, this change makes an existing published sentence there **factually false**: "`PENDING_GOVERNANCE` in `check-liveness.mts` is empty". Shipping a diff that falsifies shipped documentation is the shape this ledger's own history (objectstack-ai#7257) exists to stop — a completeness sentence no build can fail. `packages/spec`'s `files[]` ships `liveness/`, so that README is published — which is also why this PR carries a `patch` changeset rather than `skip-changeset`. Nothing under `scripts/` ships; had the README not been owed, no published byte would have moved. Both edits are prose only: the state table, its heading count (36) and `state-counts.md` are untouched, and `check:liveness` reconciles all three green. ## Acceptance notes Observed while measuring, **not** filed and **not** fixed here: - The `SPEC_ONLY_SCHEMAS` row `webhook: WebhookSchema` is now redundant with `getMetadataTypeSchema()`'s objectstack-ai#6245 fallback, which resolves the identical schema instance. Harmless and load-bearing as documentation; folding it away is the objectstack-ai#3490 reassessment's business, not this card's. - `listUnregisteredKindSchemaTypes()`'s docblock still lists `theme` among "today's" entries; `theme` was retired at objectstack-ai#10485 and the map now holds four. Stale prose in a read-only file — a doc nit, not one of the three filable classes. - Under Leg C's ablation the new coverage line reads "26 authorable type(s) — 26 registered kind(s) + 4 unregistered-kind stack collection(s)", i.e. visibly self-contradictory, because the count and the composition are two independent reads. That is a tell, not a defect: a reverted union announces itself in the line's own arithmetic. - `pnpm --filter @objectstack/spec test` was invoked with a trailing `-- --maxWorkers=2`; vitest discards everything after a bare `--`, so the suite ran at default concurrency. Recorded because the reading is the suite's, not the flag's. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: objectstack-agent <agent@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
…le def (objectstack-ai#18301) (objectstack-ai#18529) Fixes objectstack-ai#18301 Clause-②: no Executes the **C** half of the objectstack-ai#17356 ruling (batch objectstack-ai#135 item 3, maintainer 「135 同意」). A is already landed (PR objectstack-ai#18485 advanced the deletion-gate anchor); B and D were refused. This card adds a proof — it retires nothing, and it reverses nothing. > **Patch round.** The contract review of record (comment `5706880661`, served `CONTRACT_REVIEW_TIER`) returned **FAIL** on ① item 3: the proof's stated "the door is closed" condition described something the gate did not compute. This round replaces that condition, corrects every place the claim was made, and pins the case that was unpinned. Re-measuring the review's own sweep **falsified its latency finding** — see "The assumption that did not hold" below. ## What was wrong Check (c) of the `authorable-surface/` deletion gate (`packages/spec/scripts/build-schemas.ts`) admitted a deleted baseline line on three proofs: an aged-out `[RETIRED]` tombstone, an unreachable def, or a def the build no longer emits. A key retired the **strict-schema / guidance way** — deleted from the shape outright, its prescription moved into the closed shape's `guidance` table — never carries the `[RETIRED]` mark, because there is nothing left in the shape to mark. Proof 1 therefore could not apply to it **at any major**: not "has not aged yet" but "has no clock". On a reachable def that left the whole class with no proof shape at all. The class was invisible until now because proof 2 was answering for these defs — the BFS root set omitted the four unregistered kinds, so whole families read as unreachable and every deletion under them was waived as over-collection. objectstack-ai#18131 repaired the root set, and the repair is what exposes the gap. ## The assumption that did not hold The review swept for a def that could satisfy proof 4's conditions while silently STRIPPING the author's write, found none, and recorded the hole as latent. The dispatch asked for that to be re-measured. It was, **with the gate's own instrument** rather than by grep — a census pass over all 1525 emitted defs, running proof 4's declaration match and then asking each def what it does with the key. It is not latent: | def | artifact `additionalProperties` | matches one declaration by shape identity | reachable | writing `keyBy` | |---|---|---|---|---| | `shared/RateLimitConfig` | `false` | yes | `root-graph` | **parse SUCCEEDS, key dropped** | | `system/ServerRateLimitConfig` | `false` | yes (the SAME declaration) | `derived-clone` | refused, with the prescription | `ServerRateLimitConfigSchema` is declared `strictObject({… guidance: { keyBy, store } }, RateLimitConfigSchema.shape)` — built FROM the open schema's own shape object (`packages/spec/src/system/stack-server.zod.ts`, `packages/spec/src/shared/http.zod.ts`). So one declaration answers for two emitted defs, and **every fact the first cut of proof 4 read says they are the same def**. Two keys (`keyBy`, `store`) on a root-reachable def: had either baseline line been deleted, the shipped implementation would have waived it while an author who keeps writing the key has it silently dropped. That is the review's "strip-mode clone shares a strict shape" case in the spelling the tree actually holds — shape sharing in the other direction, which is why a sweep for `.strip()`, `z.object(X.shape)` and `strictObjectError()` found nothing. **No wrong verdict has shipped**: proof 4 is not on `main`, and neither key is a pending deletion. What changes is that the fix is now mandatory rather than prophylactic, and the fixture below is a real specimen rather than a synthetic one. ## What this adds **Proof 4.** A deleted baseline line is legitimate when, on a def that is emitted and reachable, **all three** of these hold in this build's own tree: 1. **the baseline entry was not `[RETIRED]`** — a guidance-route retirement deletes the key from the shape instead of leaving a `retiredKey()` in it, so it never earned the mark. This is a property of the class, not a guard bolted on, and it is what keeps proofs 1 and 4 disjoint. 2. **a `strictObject` declaration promises a prescription for the key** — the def resolves to exactly one `StrictObjectDeclaration` by shape identity, and that declaration's `guidance` names the exact key, or one of its `guidanceSets` **enumerates** it. This half says which text is owed. 3. **the def keeps that promise** — `safeParse` of that key against the schema `zodByDefKey` holds raises an `unrecognized_keys` issue naming it, and that issue's message carries the declared text **verbatim**. This half is the door. Condition 3 replaces the condition the review failed. Nothing else in the gate moves. ### Why the artifact read is gone rather than restated The failed version proved "the door is closed" by reading `additionalProperties === false` off the emitted JSON Schema. **This repo had already measured that this does not distinguish a closed door from a silent strip** and written it down: `build-schemas.ts` converts with the default `io: 'output'`, and in output mode zod emits `additionalProperties: false` for a `.strip()` object too — verified in `docs/audits/2026-07-unknown-key-strictness-ledger.md` by regenerating both ways to a byte-identical artifact. A condition that answers the same for both cases cannot be the one that excludes one of them, so it is removed, and the docblock and the author-facing remedy now say so in the gate's own words. The subtler half, which the review named and which the census above confirms: **shape identity is not a door test either.** `strictObjectError()` registers a declaration without closing the shape, `.strip()` and `z.object(Strict.shape)` clone a shape without its door, and `strictObject(opts, Open.shape)` — the live case — puts a closed declaration and an open def on the same shape entries. The identity match stays, because it is how the owed text is found; it is no longer asked to prove closure. ### Why the probe reads `unrecognized_keys`, and why it reads the message `unrecognized_keys` is the **only** issue code a `guidance` table is ever consulted from (`strictUnknownKeyError` returns undefined for every other code), and the prescription is appended to that message verbatim, one bullet per key. So the issue's presence is exactly "this def refused the write", and the declared text appearing in its message is exactly "the error map this def parses through is the one holding that table" — which shape identity alone cannot tell, since a clone can share a shape without sharing a map. No message WORDING is pinned by this: the needle is read out of the tree, from the very declaration the structural half matched, so a rewritten prescription moves both sides together. The alternative the review offered — reading `catchall` of type `never` off the instance — was measured to give identical verdicts on all four shapes tried (`strict`, `.strip()` clone, plain `z.object`, `catchall(z.string())`). It was not chosen because it proves a spelling of the door rather than the delivery of the prescription, and it would still have admitted a strict clone built without the declaration's error map. The other alternative — recording `strictObject()` and `strictObjectError()` distinctly in the registry — is a `packages/spec/src/shared/strict-object.ts` edit, outside this card's two files and across the clause-② path limb, and it would not have caught the live case above at all (both twins' declaration comes from the same `strictObject` call). ### A third verdict, and what it deliberately does not say A key a declaration names but the def does not answer for now gets its own violation line instead of the generic "was LIVE (never tombstoned)" — its `guidance` entry already exists, and what is missing is a door to deliver it through, so the generic verdict would send its reader to write something already written. That line states only **that** the prescription did not arrive, never **why**: on the shipped graph 7 of the 8 defs in that state are unions, where "the door is open" would be a guess this gate has not measured — the mistake the first cut made about `additionalProperties`, one layer down. ### Two narrowings, both deliberate, both fail-closed - **Exactly one matching declaration.** An empty shape is excluded outright — it matches every other empty shape. Where two declarations still answer, the lookup returns "no evidence" rather than unioning them. - **A `guidanceSets` RegExp does not count.** Only an enumerated `keys` list NAMES the key; a pattern claims a family whose members were never written down. ### Measured population — why this is a proof and not a blanket waiver Census over the shipped graph, run with the gate's own code (tree `944d773b8`; `packages/spec/src` is byte-identical at the head this PR now carries, `git diff --name-only` over that path returns 0 lines): | reading | value | |---|---| | emitted defs | 1525 | | defs whose emitted artifact carries `additionalProperties: false` | 1117 | | defs resolving to exactly one declaration that names an undeclared key | 258 | | keys those declarations promise | 779 | | keys the def actually delivers — what proof 4 admits | **770** | | keys promised and NOT delivered — what proof 4 refuses | **9** | Of the 9: 2 are the live case above; 7 are union defs the probe cannot drive to a single door, all of which the superseded artifact condition also excluded, so no verdict moves for them. `integration/DataSyncConfig` has no route at all (its shape is a plain `z.object` and nothing prescribes for `schedule`), so this proof cannot reach the 2026-09-10 ruling that withheld that tombstone. ## Evidence ### The pins (`build-schemas-check-mode.test.ts`) | fixture | expected | what it would catch | |---|---|---| | `data/Metric:filters` | admitted by **proof 4**, explicitly **not** proof 2 | a proof that never fires | | `data/Metric:zzNotPrescribed18301` | still refused, and NOT with the third verdict | a waiver keyed off the DEF instead of the KEY | | `integration/DataSyncConfig:schedule` | still refused | a silent reversal of the 2026-09-10 ruling | | `api/SessionResponse:zzOverCollected4650` | still waived by **proof 2**, in proof 2's words | proof 4 written as a widening of proof 2 | | `data/Object:compactLayout [RETIRED]` | falls to the tombstone chain, **not** proof 4 | the disjointness — it satisfies every other condition proof 4 tests | | `system/ServerRateLimitConfig:keyBy` | admitted by **proof 4** | — the lit half of the new pair | | `shared/RateLimitConfig:keyBy` | **REFUSED**, with the third verdict, and not waived by proof 2 either | **the review's finding**: one declaration, two defs, and a gate that reads the registry instead of the door admits the open one | The last two are ONE run and ONE declaration, which is what makes them a discriminator rather than two assertions. The `beforeAll` guard holds the tree fact they model in four loud halves: the two twins declare the same key SET, share every shape ENTRY by instance identity, the open twin ACCEPTS `keyBy` and the parsed output does not contain it, and the closed twin rejects it with a prescription bullet. If any half rots, the pin says so instead of going quietly green. Every negative assertion in the proof-4 cases was also corrected: they were written as `KEY — TOKEN` where the gate emits `KEY — def REACH; TOKEN`, so they could not have matched even on an admitted key. They now carry the `def .*` span and fail when they should. ### Ablations — both directions, on-disk proof, restored Both legs prove the mutation reached disk before any colour is read, and both restores are proved by `git hash-object` against the HEAD blob plus a whole-tree `git status --porcelain`. Each script arms a `trap` on EXIT, INT and TERM that restores the file from HEAD, against an absolute path resolved from `git rev-parse --show-toplevel`. **Ablation C — blind the door probe** (`delivers()` returns `true` unconditionally, which is the superseded implementation's behaviour for this def): - marker occurrences 0 to 1, blob `322938f2` to `682ce658` — the mutation is on disk. - run **RED**, and in the sharpest possible direction: `eager.status` came back **0**. With the door blinded the gate WAIVES `shared/RateLimitConfig:keyBy` and the whole run exits green — the hole, executed, not argued. The other two objectstack-ai#18301 cases stayed green, correctly: neither tests the door. - restored: blob back to `322938f2`, marker back to 0, `git diff HEAD` 0 bytes, `git status --porcelain` 0 lines. **Ablation D — deafen the door probe** (`delivers()` returns `false` unconditionally): - marker occurrences 0 to 1, blob `322938f2` to `3096b1af`. - run **RED**, 2 cases: both positive legs fall to the third verdict — `data/Metric:filters` and `system/ServerRateLimitConfig:keyBy` both printed `a \`strictObject\` declaration NAMES …, but writing it`. So the probe is load-bearing for the admissions too; proof 4 is not the declaration match wearing a new name. - restored: blob back to `322938f2`, marker back to 0, `git diff HEAD` 0 bytes, `git status --porcelain` 0 lines. The previous round's ablations A and B were run against the superseded implementation (their anchor, `prescribed?.has(leaf)`, no longer exists) and are **not** carried forward as evidence for this head. `scripts/ablation-dist-preflight.mjs` still reports `no dist/` for this package and is **NOT MEASURED**, not red, for the same reason as the previous round: the test spawns `tsx` over `scripts/build-schemas.ts` in a sandbox that SYMLINKS the real `packages/spec/src`, so nothing here resolves through `dist/`. The instrument that applies is the on-disk marker count plus the run's own colour, both recorded above. ## Runs Long runs went through `scripts/pm/os-verify-lock.sh`; exit codes were captured by redirect-then-`$?`, never through a pipe. `origin/main` was merged into this branch (`79a046f8c`) before this body was written, and every reading below is on the merged head. | command | verdict | |---|---| | `pnpm --filter @objectstack/spec run test:repo` | `VERDICT command-exit 0` — 31 files, 529 passed | | `pnpm --filter @objectstack/spec typecheck` | `VERDICT command-exit 0` (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`) | | `pnpm --filter @objectstack/spec run check:authorable-surface` | exit 0 | | `pnpm lint` (the repo-wide `eslint . --no-inline-config`) | exit 0 — the FULL run, not a narrowing, at `9e0324f80` | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` | 61 families derived ON THIS HEAD, not inherited | `pnpm lint` is normally CI's to run; it completed here, so the reading is the whole population eslint's own config selects rather than a subset — no narrowing claim is being made and none needs checking. All 61 derived gates were run and reconciled with `--ran`, each line carrying its exit code. 55 exit 0. Five exit **3 (PREREQUISITE NOT MET)** and are **NOT MEASURED** — each needs a built `dist/`, which this worktree has never had, and none can be moved by a diff confined to `packages/spec/scripts/**`: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt`. `pnpm check:pm-dispatch-gates` needed 807s and was recorded as `exit 124` on a first pass whose 600s wrapper fired; it was re-run without the cap and exits **0**. The record carries the real code, not the timeout. `packages/lint/scripts/check-reference-carrier-shape.mjs` is still present on this head and exits 0 — PR objectstack-ai#18503, which retires it, had not landed when this list was derived. The list was re-derived here rather than inherited from the dispatch, exactly because of that. ## Scope and publishing `packages/spec/scripts/**` matches none of the package's `files[]` entries (`dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`), and it is not a `tsup` entry — the only `scripts/` string in `packages/spec/tsup.config.ts` is a repo-root import, against a lit control of 22 `src/` occurrences. Nothing publishes, so `Clause-②: no` and `skip-changeset`. The diff is the two files the card fenced and no others: `git diff --name-only` against the merge base returns exactly those two. In particular the fix did **not** need `packages/spec/src/**` — the dispatch's stop condition on that point does not fire. ## Acceptance notes Out-of-scope observations, noted and deliberately **not** filed — none is a reproducible defect, a declared-contract breach, or a trap that makes an author write metadata the runtime rejects or silently drops: - **Superseded.** The previous round's note here claimed proof 4 "works around" the registry's door-blindness by reading `additionalProperties: false` off the emitted artifact. That was wrong, per the review and per this repo's own ledger, and the section above is what replaces it. Nothing about the registry is "worked around" now: closure is decided at the def, and the registry is asked only for the owed text. - `strictObject()` and `strictObjectError()` are indistinguishable in `strictObjectDeclarations()`, so the registry alone still cannot answer a door question. This proof no longer asks it one. Recording the two call shapes distinctly would let a future reader ask directly. Carrier: whoever next reads `strictObjectDeclarations()` for a door question. (`packages/spec/src/shared/strict-object.ts`) - 408 of the 1525 emitted defs do not carry `additionalProperties: false` on the emitted artifact. Per the review, that counts artifacts whose TOP-LEVEL field is not `false` — unions, loose objects, pipes — and is **not** the objectstack-ai#4001 ledger's strip-site population, which `check-strictness-ledger.mts` counts by AST. Carrier: the strictness-ledger worklist, which already owns that surface. - `scripts/ablation-dist-preflight.mjs` reports `no dist/` as a refusal, which is correct for a dist-mediated ablation and reads as an accusation for one that resolves through source. Carrier: none today — the script's header already prescribes the property-read alternative by hand. There is one observation this round declined to file and flags for the reviewing seat rather than burying: `shared/RateLimitConfig` is an **open** `z.object` whose shape is reused, closed, by `ServerRateLimitConfigSchema`, and the `guidance` entries for `keyBy` / `store` therefore prescribe to nobody on the open twin — an author writing `keyBy` on an API endpoint's `rateLimit` has it dropped in silence. That is objectstack-ai#4001's own failure mode on a live authorable surface, and it sits in `packages/spec/src/**`, outside this card's fence. It is a candidate class-(c) card for the triage seat, not a finding this PR may act on. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- ## Landing note (seat, 2026-09-17) Contract review at `CONTRACT_REVIEW_TIER` on head `9e0324f807`: **PASS** — record is comment `5707796462`. It supersedes the earlier **FAIL** (`5706880661`), which bound head `121465ba16` and does not bind this one. ⭐ **The re-review did not read this code, it ran it.** With no `node_modules` on the box it materialised `zod@4.4.3` and `esbuild` out of pnpm's content-addressed store, `git archive`d this head's `packages/spec/src` (archived tree hash verified equal to `git rev-parse 9e0324f:packages/spec/src`), bundled, and executed this head's own `computeGuidanceRoutes` — verbatim, `diff`-checked — against 11 synthetic door shapes and a full 1525-def census, with the OLD head's function alongside as the control. **The FAIL's one verdict-bearing item is closed, measured rather than argued:** `Strict` → prescribed, but `Strict.strip()`, `z.object(Strict.shape)`, `Strict.loose()`, an error-map object without `.strict()`, and `z.object(Strict.shape).strict()` without the map are **all refused**. Every one of those stripping forms also emits `additionalProperties: false` — which is the superseded condition's blindness demonstrated on the instance instead of quoted from the ledger. And the live twin executed both ways: `shared/RateLimitConfig:keyBy` reads `prescribed` through the OLD function (the hole, run) and `declared-but-silent` through this one. ###⚠️ Correction to this body The row 「defs resolving to exactly one declaration that names an undeclared key: **258**」 is **mislabelled**. That population measures **147**; 258 counts defs resolving to exactly one declaration *whether or not it names anything*. Corrected here because this repo squashes and the body becomes the permanent commit message. A second figure, the docblock's 「7 of the 8 defs in that state are unions」, is also wrong (9 keys on 4 defs, 3 unions) but lives **in code** — both are carried by **objectstack-ai#18579** rather than fixed in-branch, because a third push would move the head and void the review described above.⚠️ Neither figure moves a verdict or describes a safeguard, and the rationale they support (unions dominate the not-delivered set) survives the corrected arithmetic. ### Seat ruling on the process question the review referred here The review declined to rule on whether a dev may read a stop instruction by its stated rationale, and named it the seat's. **Ruling: the dev was right, and the dispatch order was at fault.** That order said 「if a live member exists, STOP AND REPORT — on the reading that it would mean a wrong verdict is shipping」. That bundles a **trigger** with a **rationale**. The dev measured the trigger TRUE, then measured the rationale FALSE (proof 4 is not on `main`; a `guidance`-only key is never in the shape, so it was never a baseline line and no deletion could ever put it to proof 4 — `keyBy`/`store` 0 in the baseline against a lit control of 1 for `enabled`), and disclosed both rather than quietly proceeding. Stopping there would have parked a proven-wrong proof in a draft and delayed a fix that had to land before this PR anyway. ⛔ This is **not** a general licence to reason past a fence. The correction belongs on the seat's side: a stop condition must be written as a **condition**, with its rationale separate and non-operative. The general rule stands — where a dev cannot measure the rationale false, the trigger governs and it stops. **Out of scope, correctly handed over rather than acted on:** the live trap the round found — `shared/RateLimitConfig` is an open `z.object` whose shape is reused *closed* by `ServerRateLimitConfig`, so an authored `keyBy` is dropped in silence — is filed as **objectstack-ai#18578**. It lives in `packages/spec/src/**`, outside this card's fence, and ⛔ was not folded in. **Pre-landing checks:** ① review PASS on record ✅ · ② `--pair 18529` exit 0; ⛔ no carriers hung (`Clause-②: no`, verified a true declaration against both limbs) ✅ · ③ re-taken at landing time ✅. Governed-surface predicate: **0 of 2 paths hit the register** ⇒ ordinary queue landing. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17356
Clause-②: yes
What was wrong
computeSurfaceReachability()inpackages/spec/scripts/build-schemas.ts— the whole of check (c) proof 2 — built its BFS root set fromlistMetadataTypeSchemaTypes(). That function unionsBUILTIN_METADATA_TYPE_SCHEMASwith theEXTRA_METADATA_TYPE_SCHEMASoverlay and, per #6245, pointedly does not enumerateUNREGISTERED_KIND_SCHEMAS.connectorlives in that third map. So the BFS never started from it,integration/DataSyncConfig— two hops away, throughconnector.syncConfigunwrapped once throughoptional— answerednull, 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[]andPUT /api/v1/meta/connector/:nameparse 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
connectortolistMetadataTypeSchemaTypes(). Two different questions wear the same words, and the gate conflated them:listMetadataTypeSchemaTypes()answers, correctly. Enrolling the unregistered kinds there would grant a status meta: bind Zod schemas for webhook / connector / sharing_rule WITHOUT registering the kinds — close the unvalidatedPUT /metawrite hole (#2657 audit, option A) #6245 deliberately withheld (enum member, registry entry, create seed, a place in the 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 campaign count), and Studio metadata coverage gaps: surface remaining types + promote un-typed concepts #2657's B/C decision stays open and unprejudged.nullis waiving a tombstone on the grounds that nobody can receive the prescription.So the gate now enumerates its own reachability root union —
reachabilityRootTypes(), readinglistUnregisteredKindSchemaTypes(), which exists (#6931) so a check can ENUMERATE that map and for nothing else, and whose listing grants nothing.listMetadataTypeSchemaTypes()is unchanged, andpackages/spec/src/kernel/metadata-type-schemas.tsis untouched by this PR.Measured, on
c548dea2The waiver, both ways. The card's literal repro key (
integration/DataSyncConfig:schedule) already landed onmainwith the #16320 retirement, so the demonstration deletes a live sibling key under the same def —timestampField, removed fromDataSyncConfigSchemaand fromauthorable-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.check:authorable-surfacegen:schemaca78860)integration/DataSyncConfig:timestampField — def not reachable from the 26 metadata-type rootsintegration/DataSyncConfig:timestampField — def reachable from the metadata-type roots; the entry at ca7886047b27 was LIVE (never tombstoned).Both legs restored from
HEADand proven byte-identical withgit hash-objectagainst theHEADblob, withgit diff HEADempty.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:
null— a root set that grows never shrinks a closure.ObjectSchema(data/Object) staysroot-graph.nullverdicts 1035 to 1018): 16 toroot-graphandshared/FieldMappingtoderived-clone. So the answer to "isintegration/DataSyncConfigthe 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/ConnectorInstanceAuthand its four auth branches,integration/ConnectorTrigger,integration/HealthCheckConfig,integration/RetryConfig.derived-clonetoroot-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-surfaceon the pristine tree is green before and after, andpnpm --filter @objectstack/spec buildleaves the working tree clean.Ablation. With
reachabilityRootTypes()swapped back forlistMetadataTypeSchemaTypes()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 bytsxfrom source with nodistin between — the new pin goes red onexpect(status).toBe(1)receiving0: the deletion is waived again. Restored fromHEAD, hash-verified.The pin
packages/spec/scripts/build-schemas-check-mode.test.tsgets 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 waygen:schemaandcheck:authorable-surfaceactually run, and the only regime wherereachableVia()can answerroot-graphat all; without the flaglazySchema()hands back a Proxy,zodByDefKeyholds 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
beforeAllfixture guard assertsconnectoris still absent fromlistMetadataTypeSchemaTypes()and still present inlistUnregisteredKindSchemaTypes()— 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.mtsbuilds its governance denominator fromlistMetadataTypeSchemaTypes()too, under the comment "i.e. exactly the set of authorable metadata types" — the same sentence [finding] the authorable-key deletion gate's reachability BFS reportsintegration/DataSyncConfigunreachable from the metadata-type roots — it is two hops from theconnectorroot, so #4650 proof 2 waives a tombstone for a key a stack manifest really authors #17356 falsified for the reachability gate.webhookis patched in by hand (anEXTRA_SCHEMASrow plusliveness/webhook.json);connector,sharing_ruleandanalytics_cubeare in neitherGOVERNEDnorPENDING_GOVERNANCE, soreport.ungovernedcannot 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 Studio metadata coverage gaps: surface remaining types + promote un-typed concepts #2657 leaves open, not a root-enumeration bug.packages/spec/src/ui/door-reachability.testkit.tsruns the same enumeration and is not blind, because it pushesObjectStackSchemaas an extra root and reachesconnectorthroughstack.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.listUnregisteredKindSchemaTypes()still counts five kinds ("webhook / connector / sharing_rule / theme / analytics_cube"); the map holds four sincedefineStack({ themes })is parsed, ingested and never applied — the last hop from a storedthemeitem to the theme engine does not exist #10485 retiredtheme. 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'sfiles[], so nothing published moves — measured:reachabilityRootTypeshas 0 hits acrossdist,json-schema,liveness,prompts,api-surface,llms.txt,spec-changes.json,README.mdandsrc/**/*.zod.ts, against a positive control (listUnregisteredKindSchemaTypes) that has 7. Henceskip-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— 甲: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:
packages/spec/authorable-surface.base.json, +1024/-1056 — merged 2026-09-16T16:37:10Z as squashfed4a15ab5. The anchor advanced tobaseRev 85c6d76ec42a…, 7804 keys.scripts/pm/os-regen-merge.sh— never a baregit merge, because.gitattributes:143routes the anchor tomerge=os-regen, a driver that exits 0 with no conflict markers while dropping one side. Merge commit59c50319(parentsdc98ee22a3+fed4a15ab5), then the wrapper's step-3 commit251d76a2, which takes main's side of the anchor and changes nothing else.UNKNOWN_KEY_GUIDANCEretirements prove themselves (batch #135 item 3, C) #18301) is untouched here — no fourth proof is added to check (c) — and the spec: retire the seven cron-typed positions nothing reads — export schedules,ScheduleState.cronExpression,DataSyncConfig.schedule,CacheWarmup.schedule, backup/DR schedules — under ADR-0049 (#15954 ruling, option A per family) #16320 retirement ofintegration/DataSyncConfig:scheduleis 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
251d76a2the committed anchor is byte-identical toorigin/main's (cmpexit 0 at both8cf527f8andfb6b2c36), andauthorable-surface.base.jsondoes not appear in this PR's diff at all. The earlier branch-local advance tob9598e9cab9d(7772 keys) is history inside commitdc98ee22a3; the head carries main's85c6d76e(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 andbuild-schemas-check-mode.test.ts+136/-2, 192+/6- — byte-identical to the original implementation diffceb66899^..c548dea2, with main having touched neither file between the original base andfed4a15ab5, nor since. Nothing was swallowed by the merge in either direction.The seven reds are gone.
Build Core,Dogfood Verify CLI,Dogfood Regression Gateand its three shards, andTemporal Conformance (live PG + MySQL)were allfailureon the pre-merge headdc98ee22a3, 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 after53ef05744f37surfaced as deletions this branch appeared to make. Advancing the anchor on main is what removed the premise. On head251d76a2, 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 underpackages/spec/scripts/and are matched by none of the package's tenfiles[]entries; the anchor is not in the diff;gen:schemaandcheck:authorable-surfaceon the pristine head both exit 0 and leavegit status --porcelainempty. 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-②: yesdeclaration 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