Repository navigation
Retire check-reference-carrier-shape; refuse an unreadable reference carrier at the reader - #18503
Conversation
… reader Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…tire-reference-carrier-shape
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check9 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fe14164448ffa74afaea9f896174162b1d3b695d && git checkout fe14164448ffa74afaea9f896174162b1d3b695d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fed4a15ab5b50633553a939e4a9793e5a17ac530 016bdeaa43e5398ac163e15e184cf226bff8c6cc && git checkout -B drift-repro fed4a15ab5b50633553a939e4a9793e5a17ac530 && git merge --no-ff 016bdeaa43e5398ac163e15e184cf226bff8c6cc
node scripts/docs-audit/affected-docs.mjs --json fed4a15ab5b50633553a939e4a9793e5a17ac530 |
At-tier contract review cannot run right now — the tier itself is refusing, and ⛔ the seat is not downgrading around itTwo isolated at-tier review attempts on this PR have now died before producing a record. Neither death is a verdict about this PR, and there is nothing to adopt from either.
Why there is no lower-tier review instead
The quota-exhaustion downgrade exemption covers dispatch, never review. And L54 names the state to sit in:
So: What is and is not affected
Recorded here with endpoint and status so the next reader can tell a refusal from a verdict. Generated by Claude Code |
Tier re-measured on the seat's hourly check-in — still refusing, and the elapsed time is the new informationOne at-tier review was dispatched for this PR at 2026-09-16T22:56Z (one, ⛔ not a loop — the seat re-measures once per check-in and does not spend repeated calls against a limit whose own message says "manage usage credits"). It terminated on the same account-level refusal:
⇒ ~3.6 hours elapsed between the first refusal and this one, with no change. That is the reading worth recording: this is not a short window that waiting out is a strategy for. ⛔ No fourth attempt is planned before the next check-in. State is unchanged and deliberately so:
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsPublic surface
Accept set
② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② carriers cleared — provenanceBoth carriers stripped in one stroke by the dispatching seat,
How this verdict was produced, since the seat is below tier. This seat's measured served model is Independence pair: What the review changed about this PR before it lands:
Pre-landing checks at this head: ① review PASS on record ✅ · ②
Generated by Claude Code |
…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>
…gh the one arbiter Ruling letter E item 2 asked for the loud refusal at EVERY reader of `FieldSchema.reference`. PR #18503 delivered it at the arbiter (`referenceCarrierOf`) and the lint read sites; these ten reads still answered "no target" for a carrier no reader can read. Each site keeps absence and unreadability as DIFFERENT answers: `null`, `undefined` and `''` still answer `undefined` and are still silent (the key is `.optional()` and `StrictField` declares it nullable); only a carrier in a shape the contract does not admit refuses. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…ce-anchor predicate consumer (objectstack-ai#18602) Fixes objectstack-ai#18535 ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」. PR objectstack-ai#17811 landed the predicate that implements it — `describeHighPrivilegeBits(def, context?)` / `describeAnchorForbiddenBits(def, anchor, context?)`, where `AnchorBindingContext.declaredCapabilities` excuses a `systemPermissions` name, the platform floor stays absolute and an omitted context refuses — and its own changeset named this follow-up: 「the plugin-security boot refusal and the lint security-anchor-high-privilege rule pass the declared list in a follow-up」. This is that follow-up. `packages/spec/**` is untouched. ## What changed, per site Premise re-verified on the branch before editing: four consumer sites, none passing a context; `declaredCapabilities` / `AnchorBindingContext` in `packages/plugins/plugin-security/src` + `packages/lint/src` → 0 hits (control: 3 in `high-privilege.ts`). | site | before | after | |---|---|---| | `plugin-security/src/security-plugin.ts` (boot bind, `bindBaselineToEveryone`) | `const offending = boot ? describeHighPrivilegeBits(boot) : null;` | `:3595` `const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null;` — context read once per pass at `:3592` | | `plugin-security/src/security-plugin.ts` (engine write gate) | `const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' \| 'guest');` | `:5503`–`:5508` the same call with `await declaredCapabilityContext()` as the third argument, memoised at `:5469` | | `plugin-security/src/suggested-audience-bindings.ts` (confirm path) | `const offending = describeAnchorForbiddenBits(setRow, row.anchor as 'everyone' \| 'guest');` | `:968`–`:972` the same call with `await readDeclaredCapabilityContext(ql, deps.metadata)` | | `lint/src/validate-security-posture.ts` (`security-anchor-high-privilege`) | `const offending = describeAnchorForbiddenBits(ps, 'everyone');` | `:795` `describeAnchorForbiddenBits(ps, 'everyone', anchorContext)`, built at `:440`–`:443` from `recordsOf(stack.capabilities)` | New module: `packages/plugins/plugin-security/src/declared-capability-context.ts` — `readDeclaredCapabilityContext(ql, metadataService)`, the registry-first / metadata-service-fallback read the `sys_capability` seeder itself uses, returning `undefined` when the stack declares nothing. ## Where the declared list is read, and why that moment is safe **Boot (the three runtime doors) reads the DECLARATIONS, not the `sys_capability` rows.** The predicate's docblock names the rows at boot; the ordering forbids it, so the card's ruled fallback applies and this is the "say so" half of it. Ordering evidence, all in `security-plugin.ts`'s `runBootstrap`: - `:3878` `for (const organizationId of catalogPasses) await bindBaselineToEveryone(organizationId);` - `:3917` `const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, …);` - `:3926` `await bootstrapSystemCapabilities(ql, …)` The binding runs 39 lines and one awaited pass BEFORE the seeder that writes `managed_by:'package'` rows, so on a first boot that table is empty at bind time; reading it there would refuse every declared token one layer in. The position is pinned by two other constraints stated in the code at `:3866`–`:3868`: the bind MUST follow `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and MUST precede `reconcileAudienceBindingSuggestions`. Nothing in the boot sequence was reordered. The same reader serves the engine write gate and `confirmAudienceBindingSuggestion` on purpose: the confirm check is the friendly early rendition of the gate that re-enforces the predicate on the insert it performs, so a second source there could answer "confirmed" and then have its own write refused under it. **Lint** reads the stack's own `capabilities:` collection through `recordsOf(stack.capabilities)` — the authoring-time source the predicate's docblock names, indexed by the same helper every other collection in the rule uses. No second declaration source was invented. ## Pins (each beside the consumer it guards, three cases per door) | file:line | case | |---|---| | `packages/plugins/plugin-security/src/security-plugin.test.ts:4372` | boot: a declared token BINDS (row asserted, not just a flag) | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4381` | boot: an UNDECLARED token still refuses (declarations present, naming a different capability) | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4392` | boot: a PLATFORM capability still refuses although the stack declares that name | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4410` | write gate: admits the declared token | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4415` | write gate: refuses the undeclared one — `code: PERMISSION_DENIED`, `statusCode: 403` (ADR-0112 envelope), message names the class | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4426` | write gate: refuses the platform capability, same envelope | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:347` | confirm: binds, and the bound row really carries the token | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:365` | confirm: undeclared still refused, suggestion stays `pending` | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:378` | confirm: platform capability still refused | | `packages/lint/src/validate-security-posture.test.ts:457` | lint: a declared token lints CLEAN | | `packages/lint/src/validate-security-posture.test.ts:473` | lint: an undeclared token still errors | | `packages/lint/src/validate-security-posture.test.ts:492` | lint: a platform capability still errors | The platform-floor cases reuse `high-privilege.ts`'s own vocabulary (`manage_users` from `PLATFORM_CAPABILITY_NAMES`), so the two layers cannot drift. The boot pins drive the METADATA-SERVICE door of the reader and the confirm pins drive the REGISTRY door, so both halves of the fallback are exercised. Three cases per door and not one: "the declared token binds" alone is equally satisfied by a door that stopped judging `systemPermissions` altogether. The lint meta-pins (objectstack-ai#5017) were visited deliberately rather than silenced: `stack.capabilities` joined the `stack` read surface and a `cap` receiver entry was added against `ObjectStackSchema.capabilities[]`, so the new read is held to the same "reads only keys the spec declares" rule as every other. ## Changesets - `.changeset/18535-anchor-declared-capabilities-consumers.md` — `@objectstack/plugin-security`: minor - `.changeset/18535-lint-anchor-declared-capabilities.md` — `@objectstack/lint`: minor `minor`, not `patch`: the PR declares `Clause-②: yes (widening)` and `check:changeset-no-major` requires at least one moved package at `minor` or above under that declaration. Both bodies carry the arm and the consumer-facing FROM → TO sentence. ## Measurements **Red-then-green, with the control lit.** Reverse verification ran from the COMMITTED fix, mutating the four call sites back to their pre-fix argument lists, proving the mutation reached the disk (anchored occurrence counts 1 → 0 for each fixed spelling, plus `git diff --stat`), and restoring under a `trap … EXIT INT TERM` with absolute paths. The subjects resolve through `src` (same-package relative imports), so no `dist` leg applies. - ablated `plugin-security` (both files): `Tests 3 failed | 293 passed` — exactly the three accepting pins (`binds an isDefault set …`, `binds the isDefault set …`, `write gate: admits …`) - ablated `lint`: `Tests 1 failed | 125 passed` — exactly the accepting pin - the six refusal controls (undeclared + platform, at each door) stayed GREEN under the ablation, which is what makes the four reds mean the context and not the predicate - restore leg proven by blob identity, not by an exit code: `git hash-object` of each of the three files equals its `HEAD` blob (`3a8fd520…`, `30c2ad7c…`, `f16fb00e…`), `git status` clean, `git diff HEAD` empty **Suites (merged tree, `1fcf14513`):** - `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security test` → exit 0 — lint `103 files / 3868 tests`, plugin-security `113 files / 2190 tests` - `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security typecheck` → exit 0, 0 `error TS` - `pnpm lint` (repo-wide `eslint . --no-inline-config`) → exit 0 — the whole population, no narrowing claimed - targeted `eslint --format json` over the 7 changed source files → 7 files, 0 errors, 0 warnings **Derived gates** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the merge: 71 families, all run, reconciled with `--ran` carrying each exit code → `71 derived, 68 run, 3 NOT-MEASURED, 0 UNRUN`. 67 green. The four non-zero: - `pnpm check:cross-package-test-inputs` → exit 1. NOT caused by this diff, proven with a control: at the base commit `e0d05538c` in a separate worktree the gate exits 0 with no `packages/spec/dist/` on disk, and exits 1 with the identical finding the moment one empty `packages/spec/dist/security` directory exists. The finding names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending into `packages/spec/dist/` — a file this PR does not touch, in a package it does not touch. Reported for filing, not fixed here. - `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n`, `pnpm check:type-check-debt` → exit 3, `PREREQUISITE NOT MET`: each refuses to measure without a full workspace build (53 packages with no `dist/`). NOT MEASURED locally, not a pass and not a finding; CI builds first and runs them for real. Three gates DID go red on this diff and were fixed, all in the new boot double: `check:engine-double-contract` (grown seam counts ratcheted with `--write`), `check:objectql-double-limit` (the `find` double now applies the caller's bound by presence, after the filter) and `check:where-matcher` (the matcher now REFUSES a `$`-prefixed combinator instead of comparing it as a field name — the refusal had to live INSIDE the matcher callback, since that gate probes the extracted matcher behaviourally). **Merge:** `origin/main` moved from `e0d05538c` to `b79fae8fb` during the work and PR objectstack-ai#18503 landed in `validate-security-posture.ts`. The one conflict was the `@objectstack/spec` import line; BOTH sides were kept (`referenceCarrierOf` from `/data` and `describeAnchorForbiddenBits, type AnchorBindingContext` from `/security`), neither dropped, and every measurement above was re-taken on the merged tree. ## Note for the contract-tier reviewer (Clause-② yes) Exactly two accept sets widen, both by the same ruled rule and both only for the `everyone` anchor: 1. the runtime anchor-binding accept set (boot bind, engine write gate, suggestion confirm) — a `systemPermissions` token THIS stack declares under `capabilities:` no longer counts as a platform system permission; 2. the lint rule `security-anchor-high-privilege`'s accept set for `isDefault: true` sets — the same names, at authoring time. What did NOT move: the platform floor (`PLATFORM_CAPABILITY_NAMES` is applied inside the predicate, so declaring `manage_users` launders nothing); undeclared names (still refused everywhere); the `guest` tier (the predicate drops the context for `guest` by contract, and no call site overrides that); the VAMA / delete / transfer / bulk-export / wildcard arms of the predicate; the boot sequence's order; and the failure direction when the declarations cannot be read — an unreadable registry, an unreadable metadata service, or an empty list all yield `undefined`, which is the pre-objectstack-ai#17811 verdict verbatim. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…(ruling E item 2 residue) (objectstack-ai#19080) Fixes objectstack-ai#18550 Clause-②: yes Ruling letter E item 2 on objectstack-ai#18095 asked for the loud refusal at **every reader** of `FieldSchema.reference`. PR objectstack-ai#18503 delivered it at the arbiter (`referenceCarrierOf`) and the lint target readers, and named the remainder itself under "What this PR does not close". This is that remainder, routed — with a per-site decision about **absence vs unreadability**, because in this design they are deliberately different answers. --- ## JOB ONE — the count, re-derived per line. ⛔ Nothing inherited. Triage's instruction was 「⭐ 数是 9,⛔ 不是 PR 正文写的 10 —— 契约复核重新数过。⭐ 这正是本班刚立的那条:**计数是会腐烂的读数** ⇒ 派发时再数一次,⛔ 不要把 9 或 10 任何一个往前抄。」 So neither number is carried forward here. What the re-derivation found is that **9 is not reachable at any single granularity** — it mixes two. Every cited line was printed at the card's stated merge base `2f11e2db7` and re-located on `origin/main@0ec8185`: | card's cited line (at `2f11e2db7`) | current line | text | moved? | |---|--:|---|---| | `objectql/src/engine.ts:13052` | `13096` | `const ref = fdef.reference;` | +44 | | `objectql/src/engine.ts:13491` | `13544` | `const ref = fdef.reference;` | +53 | | `rest/src/rest-server.ts:10835` | `10889` | `referenceObject = def?.reference;` | +54 | | `metadata-protocol/src/seed-loader.ts:701` | `701` | `fieldDef.reference` | — | | `lint/src/validate-expressions.ts:380` | `380` | `const ref = def.reference;` | — | | `lint/src/validate-field-consumers.ts:552` | `552` | `const reference = strName(field.reference);` | — | | `lint/src/validate-object-references.ts:297` | `297` | `strName(field.reference),` | — | | `lint/src/validate-object-references.ts:316` | `316` | `strName(param.reference),` | — | | `lint/src/validate-sharing-rule-enforceability.ts:261` | `261` | `const ref = f.reference;` | — | | `verify/src/derive.ts:136` | `136` | `const ref = f?.reference;` | — | **That is 10 cited LINES in 8 FILES.** Neither is 9. The 9 is reachable only by mixing granularities, and triage's own parenthesis shows the mix: 「objectql ×2、rest、metadata-protocol、lint ×4、verify」 = 2 objectql LINES + 3 non-lint FILES + 4 lint FILES = 9, counting objectql per line and lint per file. PR objectstack-ai#18503's own "10 sites" is its 11-line C3 list (the 10 above plus `validate-preset-comparands.ts:431`) with one pair merged. Both numbers are arithmetic over the same list; **the list is the fact, the count was the rotting reading.** - ⭐ `validate-preset-comparands.ts:431` (now `:450`) is confirmed **not residue**, on the same reading the contract review gave: it reads `verdict.meta?.reference`, and `meta` is the `GraphField` slice `graphFieldOf` builds — which PR objectstack-ai#18503 routed. It is covered transitively, and so are `object-graph.ts:383` and `:386`. - ⛔ Not routed, and flagged rather than widened: `objectql/src/engine.ts:9033` (`cd?.reference === parent.name` in `buildSummaryIndex`) produces the same silent skip — "can't resolve the relationship — skip", so a `summary` field never recomputes. It is **already classified**, as `engine.ts:8989` in PR objectstack-ai#18503's **C2** list, a boundary that PR drew deliberately. Routing it would widen this card past its scope; it goes to the seat as a finding instead. ### The "four routed lint read sites", re-derived The card paraphrases PR objectstack-ai#18503 as delivering the refusal "at the arbiter plus the four lint read sites". The PR's own words are **"A — changed here (6 read sites, 4 files)"**, and one of those four files is `packages/spec/src/data/field-value.zod.ts` — the arbiter itself, not lint. Measured on `origin/main`: | reading | value | |---|--:| | files holding a `referenceCarrierOf` call | **3** — all lint (`data-model-rules.ts`, `object-graph.ts`, `validate-security-posture.ts`) | | arbiter CALL sites in those files | **3** (one per file: inside `refOf`, `graphFieldOf`, `refOf`) | | lint READ sites those 3 calls cover | **9** — `refOf(` ×4 in `data-model-rules.ts`, ×3 in `validate-security-posture.ts`, `graphFieldOf(` ×2 in `object-graph.ts` | So **four lint read sites is not a reading at any granularity**: it is 3 files / 3 calls / 9 read sites, plus the arbiter's own file as the fourth FILE. The seat's lit control reproduces exactly (2 grep hits per file = 1 import + 1 call). ### The C1 set, re-derived — the card is right, and the PR body is wrong in the other direction too C1 was "explicit `typeof === 'string'` narrowing — the same silence spelled differently". Re-derived repo-wide over `packages/*/src`: **The card's C1 list, checked one by one:** | site | silence? | |---|---| | `plugin-audit/src/audit-writers.ts:472, 527, 629, 693` | ✅ yes ×4 — unreadable answers `''`/`undefined`, audit rows lose the target | | `rest/src/export-format.ts:170` | ✅ yes — `reference: undefined` in the export meta, so `referenceFieldNames` omits the column and it exports raw ids with no `$expand` | | `cli/src/commands/doctor.ts:792` | ✅ yes | | `spec/src/kernel/functional-completeness.ts:167` (now `:177`) | ❌ **NOT silence — the card is right.** It REPORTS: `FIELD_RELATIONSHIP_WITHOUT_REFERENCE`, severity error. Measured, not read: registering a fixture with an object carrier printed `[Registry] Object "task" registered with 1 functionally-incomplete field(s) … account: [error] field/relationship-without-reference` | ⇒ **6 genuine C1 sites, not 7.** **And what the card's list gets wrong in the other direction** — the thing it asked to be told: 1. ⭐ **C1 and the residue are NOT disjoint.** Six of the ten residue lines ARE `typeof === 'string'` narrowings, inline or through a `strName` helper that is one: `validate-expressions.ts:380`, `validate-field-consumers.ts:552` (`strName` = `typeof v === 'string' && v.length > 0`), `validate-object-references.ts:297` and `:316`, `validate-sharing-rule-enforceability.ts:261`, `verify/derive.ts:136`. The PR body's C1/C3 split reads as two populations; at six of ten sites it is one code shape sorted into two classes. Only four residue lines are a genuinely different shape: two truthiness gates (`engine.ts` ×2), one truthiness-plus-cast (`seed-loader.ts`), one bare assignment with no narrowing at all (`rest-server.ts`). 2. **A second C1-shaped site that REPORTS, which the list omits:** `spec/src/automation/builtin-node-config.zod.ts:527` — `typeof field.reference === 'string' && field.reference.trim() !== ''` inside a `superRefine`, falling through to `ctx.addIssue(SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED)`. So `functional-completeness.ts` is not the lone misclassification; there are two C1-SHAPED reporters, and ⛔ neither may be made to throw — a throw inside a refinement makes `safeParse` throw instead of returning `{success: false}`. 3. **Four truthiness/equality sites outside the C1 list that answer worse than `undefined`** (C2 by the PR's classification, ⛔ not touched here, reported as findings): `plugin-approvals/src/approval-service.ts:5773` does `String(f.reference)`, which turns an object carrier into the literal target name `[object Object]`; `service-analytics/src/plugin.ts:736` returns the unnarrowed carrier; `cli/src/commands/doctor.ts:709` and `:895` push it into a dependency graph. --- ## The judgement per site — absence or unreadability, and the pin `referenceCarrierOf` is the whole of the contract used here: `undefined` / `null` / `''` answer `undefined` (ABSENCE — a field is allowed to name no target; `FieldSchema.reference` is `.optional()` and `StrictField` declares it nullable), and any other shape throws a `TypeError` naming the shape and the fix. ⛔ No site throws on a falsy carrier. Every refusal pin has an absence partner and a positive control. | site | case | what changed | pin | |---|---|---|---| | `objectql` `planCascadeAtomicity` | unreadability | truthy object carrier passed `if (!ref)` and then failed both name comparisons, so the child dropped out of the referencing set and `'none'` — the one verdict asserting *nothing references this object* — could be returned over a schema nobody could read | `engine-cascade-reference-carrier.test.ts` "seam 1" | | `objectql` `cascadeDeleteRelations` | unreadability | same shape, and the one with the measured end-to-end consequence (below) | same file, "seam 2", plus an array-carrier case | | `rest` public-form lookup picker | unreadability | the carrier was read INSIDE the metadata fetch's `catch {}`; routing alone would have been swallowed into `LOOKUP_TARGET_MISSING`, so the field def is hoisted out and read after it. An object carrier was also forwarded verbatim as `query.object` into `findData` | `public-form-lookup-picker.test.ts`, 4 cases | | `metadata-protocol` `buildDependencyGraph` | unreadability | retires an `as string` cast that asserted exactly what the truthiness guard had not checked | `seed-loader-reference-carrier.test.ts` | | `lint` `masterDetailCount` | unreadability | an object carrier made a declared `master_detail` invisible to the count, so `parent` was reported unbound from metadata that declares a master | `validate-expressions.test.ts` | | `lint` `walkObject` displayField edge | unreadability | the `displayField` consumer edge was never recorded, so a field a lookup DOES display was reported carrier-only | `validate-field-consumers.test.ts` | | `lint` field target + action-param target | unreadability | `check` returns early on `undefined`, so the declaration reported NOTHING — not unknown-object, not missing-reference | `validate-object-references.test.ts` | | `lint` `masterOf` | unreadability | a `controlled_by_parent` detail whose master IS declared answered `undefined` | `validate-sharing-rule-enforceability.test.ts` | | `verify` `relationTarget` | unreadability | degraded to the generic "has no `reference` target" — the exact trade this reader already refused to make for a rejected alias | `derive.test.ts` | | `validate-preset-comparands.ts:450` | **already covered** | reads the routed `GraphField` slice — no edit, measured not assumed | (existing) | | `engine.ts:9033` `buildSummaryIndex` | **same silence, ⛔ not in scope** | equality read; already recorded as C2 in PR objectstack-ai#18503 | reported to the seat | ### `ActionParamSchema.reference` — one caveat, stated rather than buried `validate-object-references.ts:316` reads an ActionParam, not a FieldSchema field. The two are one contract by the spec's own words — `ActionParamSchema.reference`'s docblock: *"Key name deliberately mirrors `FieldSchema.reference` so the same spelling"*, declared `SnakeCaseIdentifierSchema.optional()`. The refusal text it now produces says "FieldSchema declares it as an optional STRING", which names the sibling schema rather than this one. ⛔ Widening the arbiter's message would mean editing `packages/spec`, outside this card's declared file surface, so it is reported instead of done. --- ## Evidence ### The silence, reproduced at the representative site, then refusing `objectql` cascade delete — the hot path, and the one whose wrong answer is a production behaviour change. Same probe, before and after the routing: ```text BEFORE PROBE before-delete rows: acct=1 task=1 PROBE delete outcome: RESOLVED true PROBE after-delete rows: acct=0 task=1 <- an ORPHANED master_detail row AFTER PROBE before-delete rows: acct=1 task=1 PROBE delete outcome: THREW TypeError: ObjectQL.planCascadeAtomicity: `reference` is an object, and FieldSchema declares it as an optional STRING … PROBE after-delete rows: acct=1 task=1 <- nothing touched ``` No `restrict` refusal, no `set_null`, nothing logged, and the caller told the delete succeeded. The refusal now fires before any row is touched, because `delete()` calls `planCascadeAtomicity` first. ⭐ Two facts the probe forced, both worth recording: the engine's own WRITE path already refuses this shape (`insert` runs `assertReferencesResolve` → `referenceTargetOf` → the same arbiter), so the child row had to be written straight through the driver — which is exactly the provenance the arbiter's docblock names (a raw `registerObject`, a stored row rehydrated past its schema). And the registry's own completeness check printed `field/relationship-without-reference` while the cascade stayed silent: the platform reported the shape in one channel and mis-read it in another. ### Ablation — the pins can fail, proved on disk by content hash One routed read reverted through `scripts/ablation-replace.mjs` (⛔ not `sed -i`), on the committed tree: ```text anchor "const ref = referenceCarrierOf(fdef, 'ObjectQL.planCascadeAtomicity');" x1 -> x0 replace "const ref = fdef.reference;" x0 -> x1 blob 4ca2839 -> 6121f5c9bf429db31f959af2e43711506db8baf0 ok mutation landed: anchor 1 -> 0, blob 4ca2839 -> 6121f5c9bf42 x seam 1 (planCascadeAtomicity): an unreadable carrier refuses the delete BEFORE any row is touched Test Files 1 failed (1) Tests 1 failed | 5 passed (6) restore blob after restore 4ca2839 blob at HEAD 4ca2839 git diff HEAD empty ``` Restored with the explicit form, verified independently afterwards: `git diff HEAD` empty and `git hash-object` equal to the HEAD blob. The redness is targeted — exactly the mutated seam's pin failed while seam 2 and all three absence controls stayed green, so the pins are per-site rather than one shared assertion. ### Tests | package | result | |---|---| | `@objectstack/objectql` | 300 files / **5009 passed** | | `@objectstack/rest` | 194 files / **3245 passed**, 1 skipped | | `@objectstack/metadata-protocol` | 180 files / **2585 passed**, 19 skipped | | `@objectstack/lint` | 104 files / **3935 passed** | | `@objectstack/verify` | 15 files / **116 passed** | | `typecheck` (all five, incl. test layers) | exit 0; `objectql`'s shrink-only test ledger held at 40 files / 234 errors / 65 pinned signatures — not raised | ⭐ `check:type-check-debt` re-measured all 4 DEBT ledger entries at the final head: 53 raw errors, **none above its recorded number**. ### Repo-wide lint — run, ⛔ not narrowed `pnpm lint` (`eslint . --no-inline-config`) at `bd1b3dcad`: **exit 0**, 75s. No narrowing is claimed, so no narrowing evidence is owed. ### Gate census — derived from the REAL change set >⚠️ **Seat correction, added after the body's single dev write.** The census in this section, and the widening-tells reading below it, were taken at head `bd1b3dcad` (17 paths) and are described here as 「at the final head」 — they are not. The doc commit made the head `a17094d94` (18 paths), and `dispatch-gates.mjs --commands` derives **93** families there, the extra **28** being the doc family (`check-doc-frontmatter`, `check:doc-anchors`, `check:docs-single-h1`, `check:docs-redirects`, `spec check:docs`, `check:skill-examples`, …). The dev re-derived and ran them — 93 derived / 92 run / 1 NOT MEASURED, and the at-tier reviewer independently ran all 28 (27 exit 0, 1 prerequisite refusal, bound green by CI's `Lint & Repo Gates`) — so ⛔ no outcome is at stake; the numbers written in this section are simply not the ones for this head. Likewise 「17 changed paths」 and 「17 NOT MEASURED」 read 18 here. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head, every command run with `$?` captured **before any pipe**, then reconciled with `--ran`: ```text Run reconciliation — 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. NOT-MEASURED · DERIVED (1) — your record carries exit 3 for these, the number a gate refusing its own prerequisite exits with: - pnpm check:dual-build-cjs-loads [recorded exit 3 on line 41 — PREREQUISITE NOT MET] ``` - **64 families exit 0.** - **`pnpm check:dual-build-cjs-loads` — `PREREQUISITE NOT MET` (exit 3), ⛔ neither a pass nor a finding.** Its own words: *"this gate reads built output, and some package has no dist/ … Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured."* 35 packages outside this card's build closure have no `dist/`; the prerequisite is a repo-wide build, which is what CI does before this step. Bounding fact, measurable from the diff: **zero** `package.json`, `tsup`/`tsconfig`, `turbo.json` or workflow files are touched — 17 changed paths, all `.changeset/` plus `packages/**/src`. - Two gates were **red or unmeasured first and fixed at the source, ⛔ never at the ledger**: - `check:objectql-double-limit` graded the new test file's `find` double limit-blind. Fixed in the double (the caller's bound applied after the filter, by presence). ⛔ The baseline was not touched; the re-run prints `baseline key set verified against 0ec8185: no files added`. - `check:type-check-debt` exited 3 twice on its own prerequisite — first for an unbuilt `@objectstack/driver-turso`, then because the ablation's restore rewrote `engine.ts` and left `dist/` older than source by mtime. Both satisfied by building, then exit 0. ⛔ No ledger entry was raised on an exit 3, which that gate forbids explicitly. ### Clause ② — the widening-tells reading, with its NOT MEASURED rows `node scripts/pm/check-widening-tells.mjs --declaration yes --diff …` → **exit 0**: ```text ✓ check-widening-tells: the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ```⚠️ That is a **short-circuit, not a reading**: with `yes` the gate examines no file. So the same diff was also run with `--declaration no` as a DIAGNOSTIC (⛔ not a declaration — the declaration is `yes`, and it is the seat's), to get the per-file reading: ```text ✓ check-widening-tells: 17 changed file(s) — 0 judged against a declared surface (no widening tell), 17 NOT MEASURED. ⛔ NOTHING on this diff was examined for widening tells, so this exit 0 is evidence about no surface at all. ⛔ NOT MEASURED is not a clean reading: no declared surface covers it (17): … all 17 changed paths … ``` ⇒ **17 NOT MEASURED rows, zero judged. ⛔ Not a clean reading**, and reported as such: the gate's four tells cover Zod schema surfaces, closed sets, published export listings and registries, and this diff touches none of them. `--self-test` passes (510 cases). The declaration stands at `yes` on the ground the seat's claim records — the observable behaviour at published runtime doors (`rest-server.ts`, `objectql` cascade delete) changes — ⛔ and this PR does not review its own verdict. ### Changeset One changeset, **`minor` × 5**, with the level's reason stated rather than assumed. `minor` and not `patch` because the declaration is `Clause-②: yes` and `check-changeset-no-major`'s level axis requires at least one moved published package graded `minor` or above; `minor` and not a breaking grade because nothing conformant changes — a non-string `reference` could not be authored, stored or parsed before this release either. That is the same grading and the same "Upgrading: nothing conformant changes" reasoning PR objectstack-ai#18503's own changeset shipped for the same class of change, at the same launch-window convention. --- ## Docs — one page was falsified, and it was not on the bot's list *Added by the `domain:spec#3` seat after the body's single dev write: this row did not exist when the body was written, and the dev does not PATCH a PR body.* Docs Drift Check re-derived on the merge tree the bot names (`f3a03768de7b46fea7e8637158666569825c8f23`, **not** the PR head), in a separate detached worktree so this branch never moved, reproducing the comment exactly: 28 docs, 7 release-owned, 21 hand-written, 9 anchors, 1 anchorless change, 38 package-mention pages. **The 21 hand-written rows are clean, and the reason is worth stating**: 20 of them came in through a single weak anchor — the literal `master_detail`, which this diff only MOVED (the seed-loader `if` was rewritten) — and the eight symbol anchors (`planCascadeAtomicity`, `cascadeDeleteRelations`, `masterDetailCount`, `masterOf`, `relationTarget`, `validateObjectReferences`, `walkObject`, `buildDependencyGraph`) match **no** hand-written page at all. Each falsifiable statement in the 21 was re-read by hand and judged; none asserts the silence this change removes. **The one page this change did falsify was invisible to that run, by the bot's own declaration**: `packages/rest/src/rest-server.ts` yields no doc anchor, so the page documenting that route could not appear on its list 「on this run or any run」. Found by a hand grep for `LOOKUP_TARGET_MISSING` across `content/docs`: **`content/docs/ui/forms.mdx`**, whose public-form lookup picker error table read as covering both absence and unreadability — the exact distinction this routing exists to draw. Fixed here in two table cells: one clause on the `object` key, and a new row for the `500 INTERNAL_ERROR` envelope with the reason it is deliberately **not** `LOOKUP_TARGET_MISSING`. ⛔ Not broad doc rewriting. ⛔ **The 7 release-owned pages are read-only and byte-untouched** (`git status content/docs/releases/` empty). Each was checked for falsification and none is: six appear only via the weak `master_detail` literal, and `v17/17-1.mdx` names `cascadeDeleteRelations` for the *registry* read (objectstack-ai#9002) rather than this carrier read. The generated `references/api/{contract,error-code-ledger}.mdx` also name the code but are auto-generated and this change adds no error code. ## Acceptance notes — noted, not filed (⛔ dev files no cards; these go to the seat) The three-class findings above (a `String(f.reference)` that invents `[object Object]` as a target name; the remaining C2 truthiness/equality readers, `engine.ts:9033` included; the arbiter message naming `FieldSchema` at an ActionParam read site) are reported to the dispatching seat with dedup words, which runs dedup and files. Also noted and ⛔ not filed: - The `{ reference: X.reference }` wrapper form at the four lint sites and in `verify/derive.ts` is deliberate and is not stylistic drift: the literal `.reference` read stays at the site so the objectstack-ai#5017 receiver meta-test in `packages/lint` keeps its subject (it reads the rule's SOURCE to prove it reads `reference` and never an alias, and folding the read into a helper call disarms that scan silently — it went red on exactly that during this work), and `relationTarget`'s docblock makes the same key-spelling argument at length. The three runtime readers pass the field def directly, where the def is the natural argument and no such scan exists. Successor for the asymmetry: whoever routes the C2 population next. ## The open question this card carries — ⛔ not answered here Whether arbiter + lint routing satisfied ruling item 2, or whether PR objectstack-ai#18503 was owed the residue too, is recorded as the maintainer's to give. ⛔ This PR does not answer it and landing it is ⛔ not an answer. Two pieces of evidence bearing on it surfaced and are offered without a verdict: the ten residue lines were a **measurable** population at the time (each printed and read), which bears on whether they were reachable in that PR's scope; and six of the ten are the SAME code shape PR objectstack-ai#18503 sorted into its deliberately-unchanged C1 class, so "the residue" and "the boundary" were not two populations but one, sorted twice. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…pagating an unreadable `reference` (objectstack-ai#19197) Fixes objectstack-ai#19081 Clause-②: no Four readers of `FieldSchema.reference` gated the carrier with a **truthiness** test, which a non-string object passes, and then propagated the value onward. The declared contract is an optional **string**, so the answer a reader owes for a carrier it cannot read is **absence**. The approvals site did worse than lose the information — `out.push({ key, reference: String(f.reference) })` **manufactures** the literal target name `[object Object]`, and hands it on as an object name to `engine.find()`, where the failure disappears into the caller's own `catch`. These four are the class PR objectstack-ai#18503 labelled **C2** and deliberately left unchanged. This PR acts on that class; it is not a claim that objectstack-ai#18503 or objectstack-ai#19080 was wrong to leave it, and it establishes no reachability from an authored document — the engine's write path refuses this shape, so a row carrying it had to be written straight through the driver. Cheap insurance, not an incident. ## The design question the card leaves open, and what decided it Whether these sites should route through the arbiter `referenceCarrierOf` or narrow locally. Measured first, because the arbiter does **not** return the same narrowing a local `typeof` test would: it **throws** a `TypeError`. That is extra knowledge, and at these four sites it is hostile knowledge: | site | what an escaping throw would do | |---|---| | `ApprovalService.resolveLookupFields` | the whole body is inside `try { … } catch { return []; }`, so one unreadable field would drop **every** lookup field of the object | | analytics relationship resolver | a `TypeError` inside dataset compilation, replacing the compiler's own "cannot resolve this relationship" refusal with a crash | | `os doctor` (both sites) | aborts the diagnostic run on exactly the broken metadata `doctor` exists to report | So the repair is **both**: the carrier is read through the one arbiter — no fourth hand-copy of the narrowing, and `''` / `null` / `undefined` keep their absence semantics — and the refusal is caught **at the site**, which yields absence plus one report. That is the deliberate line between these readers and the `@objectstack/objectql` cascade seams objectstack-ai#19080 landed, which let the same refusal propagate: those assert something positive about the schema on a **write** path, where the silence cost an orphaned `master_detail` row and a reported success. ## Per site - **`@objectstack/plugin-approvals`** — the unreadable field is left out of the inbox display enrichment and logged through the service's existing `logger.warn`. It is **dropped** rather than pushed with the target absent because the sole consumer destructures `{ key, reference }` and uses `reference` as the object-name argument to `engine.find`; an entry carrying none has nothing for that consumer to do, and keeping it would widen the declared return type for no reader. The effect there is the one this best-effort resolver already produces for every other unresolvable case — the entry stays unresolved. - **`@objectstack/service-analytics`** — the ADR-0021 relationship resolver answers `undefined`, which its existing fallback turns into the dataset compiler's refusal, plus one `ctx.logger.warn` naming the field. - **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks **report** the unreadable carrier as a finding rather than skipping it. Both publish a positive verdict — "No circular references detected", "defined but not referenced" — that an edge nobody could read cannot support, and both already return diagnostic strings that `doctor` prints as warnings, so being loud here needed no new channel. The same file's `collectViewObjectRefs` already narrowed its own carrier. ## Tests, and the ablation behind them Each of the four sites has a case that fails **before** the change and a readable-target control beside it, so "narrowed" and "this path is now closed" stay distinguishable. The `[object Object]` string is pinned directly, since it is the card's whole evidence. Ablation: each narrowing was reverted to its truthiness gate through `scripts/ablation-replace.mjs` (anchor must hit; on-disk counts and blob hashes are the tool's own verdict), the pin re-run, then restored. Every site is source-imported by its suite, so no `dist` round trip is involved. Restores verified by the tool: `blob == HEAD` and `git diff HEAD` empty. | site | ablated run | |---|---| | approvals | 3 failed / 1 passed — `expected [ 'crm_account', …(2) ] to not include '[object Object]'` | | analytics | 2 failed / 1 passed — `expected { name: 'shop_invoice', fields: {} } to be undefined` | | doctor (both) | 2 failed / 3 passed — the two carrier findings vanish | ## Verification All commands run at `1130dc81b`. - `pnpm --filter @objectstack/plugin-approvals --filter @objectstack/service-analytics test` — exit 0; 48 files / 775 tests and 113 files / 2411 tests. - `pnpm --filter @objectstack/cli exec vitest run --project unit` — exit 0; 218 files / 3069 tests. The `integration` tier is declared to CI: the diff touches no integration-tier test file, no `bin/` entry and no driver or kernel boot path. - `pnpm --filter … typecheck` for the three packages — exit 0, test layer included. - Gate family re-derived for the actual diff with `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: **64** families, each run with its exit code landed to a file, then reconciled with `--ran`: **60 run, 4 NOT MEASURED, 0 UNRUN**. The four are `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`, each exiting **3 = PREREQUISITE NOT MET** in this container because they read whole-tree built output. Not a pass — declared to CI. - `eslint . --no-inline-config` — exit 0 over the **6900** files eslint's own config reads, 0 errors / 0 warnings. Type-aware linting is not enabled in `eslint.config.mjs`, so no untouched file's verdict can move with this diff; this is the full population rather than a narrowing. ## Acceptance notes - `packages/cli/src/commands/doctor.ts`'s `detectCircularDependencies` gained an `export` so its carrier reading is assertable; it has no other caller, and `doctor.ts` is not a declared entry in this package's `exports` map, so nothing is added to the published API surface. That is the only change in the file beyond the two gates the card names. - Census re-run. The card's reading — one `referenceCarrierOf` hit across `packages/objectql`, `plugin-approvals`, `service-analytics` and `packages/cli`, and it is a test — no longer holds for that four-package corpus: `packages/objectql/src/engine.ts` now carries four production hits, landed by objectstack-ai#19080 after the card's ref. Inside this PR's three packages the zero stood (only `packages/cli/test/data-model-rules.test.ts`), with the token resolving in 20 files repo-wide as the live control. - Noted, not filed: `collectViewObjectRefs` (same file) narrows with a bare `typeof` test, so it admits `''` as a target name and reports nothing when a carrier is unreadable. Out of scope here — it is not one of the four propagating sites, and it already answers absence rather than propagating. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…`reference` carrier is unreadable (objectstack-ai#19293) Fixes objectstack-ai#19082 Clause-②: no A diagnostic added to an internal, private index neither loosens an accept set nor widens a published surface. No schema changed; `buildSummaryIndex` is `private` and nothing about its signature, its return shape or its resolution rule moved. ## The premise, re-taken by symbol Triage said it had not re-taken the reading and asked the executor to. `packages/objectql/src/engine.ts` took a lander after the card's reading ref `221dabb72` — `a675ad4e` (objectstack-ai#19080, the ten-residual-readers round) — so the site was re-located by **symbol**, never by the card's line numbers. It still resolves by carrier equality. `buildSummaryIndex` is at `engine.ts:9009`; the comparison the card quotes at `:9033` is byte-identical and now sits at the same line, and the silent `continue` at `:9039` is unchanged. objectstack-ai#19080 routed `planCascadeAtomicity` and `cascadeDeleteRelations` through the arbiter and left this third site alone. **`premise_still_valid: true`.** ## The defect The child-to-parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw, so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and ```ts if (!fkField) continue; // can't resolve the relationship — skip ``` dropped a **declared** `summary` field out of *both* indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as objectstack-ai#9154. ## The boundary this card asked to reopen — and where it now stands PR objectstack-ai#18503 recorded this site in its **C2** list and the objectstack-ai#18550 round left it there deliberately. **That boundary stands: the resolution rule is untouched.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive — a roll-up quietly aggregating the wrong children reads exactly like a correct one, while a roll-up that stopped moving is at least visible to anyone who looks at the value. What ends here is only the **silence**, which triage named as the half available today: - the carrier is read through the one arbiter, `referenceCarrierOf` — the accessor objectstack-ai#19080 routed the two cascade seams through; - its refusal is **caught** rather than propagated, because this is a *scan* looking for the FK across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the foreign key, turning a roll-up that works today into a hard failure of every write to that child. Pinned (§5 of the new test); - the skip reports itself at **`error`**, once per index build. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class by AGENTS.md's own question, and the line carries both halves it owes: the consequence (which field will not recompute, and that the system keeps looking healthy) and the fix (spell the carrier as the target object's name, or name the FK with `summaryOperations.relationshipField`); - **absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is legal; they skip silently exactly as before. Every readable carrier resolves exactly as before. The decision and its reasoning are recorded on the card and in the function's own docblock, so the next reader of the skip branch finds them instead of re-filing. ## Reachability — measured, and deliberately not inflated The card recorded this as **not established**, and it is now measured on this tree rather than argued. One probe, three doors, each with a readable-carrier control that passes: | door | shape `{ object: 'bad' }` on a `master_detail` | control `reference: 'bad'` | |---|---|---| | `ObjectSchema.safeParse` (the contract door) | **REFUSED** — `fields.bad.reference: invalid_type` | accepted | | `getMetadataTypeSchema('object')` — what `saveMetaItem` resolves for a stored `/meta` write | **REFUSED** | accepted | | `registry.registerObject` — the choke point every metadata door funnels through | **ACCEPTED**, carrier stored verbatim as `{"object":"bad"}`; `referenceCarrierOf` on the registered field throws | accepted | So: **not a live outage** — the live authoring and stored-write doors refuse this shape today — and **not unreachable either**. The registry takes it raw, which is the population `engine.ts`'s own objectstack-ai#9689 note already names for the sibling seam: "a raw `registerObject`, or a stored/artifact row written before the tightening — the two populations parse-time rejection measurably cannot catch, since the engine registers raw objects and never re-parses". Graded exactly there, and ⛔ not escalated: no stored `summary` field was measured to have never recomputed, which is this card's only escalation condition. One honest qualifier, measured in the same probe: registration **does** already emit an ADR-0078 completeness warning for this field (`field/relationship-without-reference` fires on `typeof def.reference !== 'string'`). That is a one-shot, console-carried note about the **child field** at registration; it does not name the **parent's** declared `summary` field, does not say the roll-up was dropped, and this package's own vitest config quiets `[Registry]` output to `warn`. It is a neighbouring signal, not this one. ## Tests `packages/objectql/src/engine-summary-index-unreadable-carrier.test.ts`, 7 cases, both directions — because without the second, a change that simply stopped resolving anything would be indistinguishable from a fix: - **§1 control** — a normal `reference` still resolves `fkField` (`inv_line` / `inv`), and the recorder stays at zero; - **§2 the defect** — an unreadable carrier emits the skip signal, at `error` and not `warn`, naming the field, the consequence and both fixes; - **§3** — both in one index build: the readable roll-up is indexed while the unreadable one is reported; - **§4** — absence stays silent; - **§5** — an unreadable sibling declared *before* the real FK does not hide it; - **§6** — said once per index **build**: five consults report once, and a registry mutation makes it report again (without that second leg a "1" could equally mean "once per process"). The zeros in §1, §4 and §5 are readings rather than a dead instrument: §2 drives the same recorder through the same handle and measures it at 1. Every command below captured its exit code before any pipe, at HEAD `308a3403`: | command | result | |---|---| | `pnpm --filter @objectstack/objectql test` | **301 files / 5016 tests passed** | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 — and `check:test-typecheck` holds at 40 files / 234 errors / 65 signatures, unmoved | | `pnpm --filter '@objectstack/objectql^...' build` | exit 0 | | `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` | 72/72 successful | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` then `--ran` | **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN** — every family carries a recorded exit code and none is 3 | | `pnpm lint` (`eslint . --no-inline-config`, whole repo — no narrowing to declare) | exit 0 | Five of the 62 first returned exit 2 or 3 — never a pass, nothing measured — and each was cleared rather than reported as one: `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test` refused on a shallow clone (deepened with `git fetch --shallow-since=2026-06-15`, both then exit 0), and `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` refused for want of built output (built, then exit 0). ## Acceptance notes Out-of-scope observations, noted and deliberately **not** filed: - **noted, not filed** — the `!fkField` skip is still silent in its *other* branch: a roll-up whose child declares **no** relation field at all is dropped with no diagnostic here. It is not this card's input, it is loud at a different layer (the ADR-0078 completeness rule fires on exactly that shape at registration, at `severity: 'error'`), and widening the new diagnostic to cover it would make every legitimately-unresolvable `summary` declaration log per index build. Successor: whoever next reopens PR objectstack-ai#18503's C2 boundary for this function — the decision is now recorded in `buildSummaryIndex`'s docblock, where they will meet it. - **noted, not filed** — when an unreadable sibling carrier sits beside a readable FK that does resolve, the unreadable one is passed over silently (§5 pins that it does not break resolution). Nothing is dropped on that path, so there is no defect to report; the carrier itself is already reported by the ADR-0078 rule at registration. Successor: none — no PR or reader reaches this path with a question the ADR-0078 warning does not already answer. Sibling card objectstack-ai#19081 shares the same root (an unreadable `reference` carrier) and is deliberately **not** folded in: different file, different failure direction (it leaks the carrier onward; this one silently drops work), different lane. Triage ruled both should be taken, in either order. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18095
Executes the maintainer ruling on this card (comment
5699305034, letter E): retirecheck-reference-carrier-shape, and move the defect class it guarded into the reader, which now refuses a carrier it cannot read instead of answering "no target". The direction is ruled; this PR is execution.⭐ First step of the dispatch — the reader census
Two different populations, and they are not the same size. Both re-derived on the merge base, both with a same-subject control.
git grep -l 'refOf(' -- 'packages/'git grep -l 'FieldSchema' -- 'packages/'= 236 filesgit grep -l -E '\.reference\b'overpackages/**TS/TSX/MJS['reference']= 6 lines; same-syntax control['type']= 36 filesBoth
refOf(numbers reproduce the seat's pre-dispatch measurement exactly (5 and 236). The classification behind the 5 does not. Only two of those five files readFieldSchema.reference:FieldSchema.referencereader?packages/lint/src/validate-security-posture.tspackages/lint/src/data-model-rules.tspackages/lint/scripts/check-reference-carrier-shape.mjspackages/metadata-core/src/contract-suite.tsrefOf = (overrides) => MetaRefbuilding{org, type, name}. Nothing to do with fieldspackages/metadata-protocol/src/sys-metadata-repository.contract.test.tsMetaRefbuilderSo
refOf(is a good handle on the named instance and a poor census of "every reader". The real census is the 73-file.referencepopulation, classified below.The census, by what each reader does with a non-string today
A — changed here (6 read sites, 4 files). Routed through one refusal:
packages/spec/src/data/field-value.zod.ts—referenceTargetOf, the declared single arbiterpackages/lint/src/validate-security-posture.ts—refOfpackages/lint/src/data-model-rules.ts—refOf, plus the R8 options-source read and the R7 summary-target readpackages/lint/src/object-graph.ts—graphFieldOf, the slice every other lint rule reads downstreamB — inherits the refusal with no edit, because it already asks the arbiter (
referenceTargetOf, 16 files by grep):objectql/src/engine.ts($expand),objectql/src/integrity/dangling-reference-audit.ts,objectql/src/record-title.ts,metadata-protocol/src/protocol.ts,service-analytics/src/dimension-labels.ts.C — measured, deliberately unchanged, listed so the boundary is visible:
typeof === 'string'narrowing (the same silence, spelled differently):plugin-audit/src/audit-writers.ts(×4),rest/src/export-format.ts,cli/src/commands/doctor.ts,spec/src/kernel/functional-completeness.ts.=== 'sys_user'is false either way):driver-mongodb/src/mongodb-schema.ts,plugin-sharing/src/sharing-rule-service.ts,objectql/src/engine.ts:8989,service-analytics/src/plugin.ts,spec/src/data/default-value-shape.ts,rest/src/export-format.ts:185,plugin-approvals/src/approval-service.ts.undefinedsilently — the measured residue, 10 sites:objectql/src/engine.ts:13052and:13491(cascade delete),rest/src/rest-server.ts:10835,metadata-protocol/src/seed-loader.ts:701,lint/src/validate-expressions.ts:380,lint/src/validate-field-consumers.ts:552,lint/src/validate-object-references.ts:297and:316,lint/src/validate-sharing-rule-enforceability.ts:261,lint/src/validate-preset-comparands.ts:431,verify/src/derive.ts:136. Not silently left out — see What this PR does not close below.D — must NOT throw, on purpose: the schema's own
superRefinevalidators (spec/src/data/field.zod.ts,spec/src/ui/action.zod.ts,spec/src/automation/builtin-node-config.zod.ts). A throw inside a refinement makessafeParsethrow instead of returning{success: false}— that would destroy the loudness at the contract door this whole change leans on.E — ⛔ not touched (ruling item 3):
LOOKUP_TARGET_COLUMNinservice-automation/src/builtin/screen-nodes.ts(#17306). The constant is byte-unchanged; only its docblock's now-stale reference to the retired gate was corrected.The ruling's premise, verified rather than assumed
The ruling rests on "the protocol already refuses the shape at the contract door". Measured against the built spec, not recalled:
The premise holds: an object-valued carrier is refused, located, at load.
referenceisz.string().optional()atpackages/spec/src/data/field.zod.ts:1251, andInlineGridColumn's at:874.That the reader change ends #13053's class
(#13053 is referenced here as the incident this change answers. This PR does not close it — the wording below deliberately keeps every closing keyword away from its number, and GitHub's link table confirms it: this PR's only closing keyword is⚠️ Corrected at landing: an earlier revision of this body said #13053 「remains open」. It does not — #13053 was closed 2026-08-29 with
Fixes #18095.state_reason: completed. The claim was wrong when written; the contract review measured it.)The worked example is the fixture the retiring gate's own header names.
packages/cli/test/data-model-rules.test.tsused to assert thatreference: { object: 'project' }resolved to nothing and producedrelationship/missing-reference— a finding about the wrong thing, since the target is not missing, it is unreadable. It now asserts the refusal, with two controls:TypeError, message matching/`reference` is an object/and/FieldSchema declares it as an optional STRING/— ⛔ not a baretoThrow(), which an unrepaired reader throwing anyErroron any input would satisfy;reference: 'project'lints clean, so the throw is about the carrier's shape;missing-referencefinding, not a throw — absence and unreadability stay different answers.null,undefinedand''are absence and never throw. That is the retiring gate's own documented position ("nullis not a wrong carrier — it is an absent one"), andStrictFielddeclaresreferencenullable.What newly throws across the tree
Nothing, measured. The retiring gate's final census, taken on the merge base immediately before deleting it:
Zero non-string literals at a field-def carrier position. The one non-literal counter-example in the tree is the cli fixture above, which is re-keyed here. No real (non-fixture) call site passes a non-string today.
Removal hygiene
lint.yml: the step and its comment block removed (32 lines).package.json: nocheck:alias existed — the gate was invoked by path (grep exit 1; controlcheck:doc-security-postureresolves in 5 files).Tree sweep after removal, hard-wrap-safe (
grep -rlz, because this prose wraps the filename across lines and a line-oriented grep returns a false zero):check-reference-carrier-shapecheck-doc-security-posture= 4check:reference-carrier-shapecheck:doc-security-posture= 5All six survivors are prose, now past-tense and stating the retirement. No roster entry, no workflow line, no
package.jsonalias, no import. Comment-masked counts:check-self-test-wired.mjs2 in code,dispatch-gates.mjs1 in code — both are synthetic fixture strings feeding pure-text matchers (noexistsSync, no spawn), and they are the cases that now hold the grammar.This gate was the tree's only package-local by-path gate invocation, and three other gates pinned it by name as their live specimen. Measured:
So the lane did not move, it emptied — and the pins' own instruction ("re-point this pin at the new specimen") has no specimen to point at. Five live assertions were converted, each keeping what it could still hold:
scripts/check-self-test-wired.mjs— the export pin now holds the derivation (packageLocalis exactly the part ofpopulationthe root walk did not produce), which is true at zero and at one; the corpus pin holds the anchor against minting a climbing key. The syntheticbattery('left boundary')still drives the grammar.scripts/check-self-test-workflow-commands.mjs— both pins now quantify over the whole imported population, so they hold at zero members and start judging the day one returns.scripts/pm/dispatch-gates.mjs— both live pins become a zero with its control (nopackages/…direct invocation, against 143 root ones from the same extraction), so an extraction that stopped matching is still caught.What is genuinely weaker: no live reading now proves the package-local admission path end-to-end. The grammar is exercised only synthetically until some future gate is invoked by a package-local path.
What this PR does not close
The C3 residue above — 10 raw
.referencereads that still answerundefinedsilently. Routing them means touchingobjectql,rest,metadata-protocolandverify, several on hot runtime paths, and each needs its own judgement about absence vs unreadability. They are measured and named here rather than swept in; the arbiter change already covers every consumer that asksreferenceTargetOf.Acceptance notes
packages/lint/scripts/keeps two sibling gates (check-doc-formula-expressions,check-doc-security-posture), both invoked aspnpm --filter @objectstack/lint run check:*and therefore outsidecollectInvocations' population entirely. Pre-existing, not this PR's; recorded so the next reader does not derive it as a wiring gap.scripts/check-self-test-wired.mjs,scripts/check-self-test-workflow-commands.mjs,packages/lint/src/object-graph.ts,packages/spec/src/data/field-value.{zod,test}.ts, the two regenerated spec artifacts,screen-nodes.tsandbuiltin-node-config.test.ts(both comment-only), and the changeset — every one of them required by ruling item 1's "every roster/family that names it" or item 2's reader change.Verification
pnpm --filter @objectstack/spec testpnpm --filter @objectstack/lint testpnpm --filter @objectstack/cli exec vitest run --project unit test/data-model-rules.test.tspnpm --filter @objectstack/spec check:generatedapi-surface/+export-origins/regenerated:0 breaking, 1 added)node scripts/check-self-test-wired.mjs+--self-testnode scripts/check-self-test-workflow-commands.mjs --self-testnode scripts/pm/dispatch-gates.mjs --self-testpackages/cli'sintegrationtier is declared to CI: the diff touches no spawn entry point. The repo-widepnpm lintsweep is CI's.Clause-②
The seat declared yes (claim
5700063579) and hangsneeds:contract-reviewitself. The api-surface reading substantiates it: 1 added, 0 breaking —referenceCarrierOfis a new published export on@objectstack/spec/data. Changeset is minor for@objectstack/specand@objectstack/lint, as ayesrequires. NoClause-②line is written into this body; the carrier is the card's.Governed-surface predicate on the final file list: 0 of 16 hit the register — ordinary queue landing.
Generated by Claude Code
Landing note (seat, 2026-09-17)
Contract review at
CONTRACT_REVIEW_TIERon head016bdeaa43: PASS — record is comment5706700015on this PR.Fixes #18095closing the card cannot lose it. ⛔ The seat did not treat landing as an answer to that question — it is carried to the maintainer on #18550 and in the round report.The review also re-measured two counts this body states: the C3 residue is 9 sites, not 10 (
validate-preset-comparands.ts:431reads theGraphFieldslice and is already covered by the arbiter change), andspec/src/kernel/functional-completeness.ts:167is misfiled under C1 — it reports a non-string reference as an incompleteness finding rather than answering silently. Both corrections are recorded on #18550 for whoever takes it.Non-blocking, recorded not fixed: the changeset's upgrade sentence names 「a hand-built fixture or a raw registry entry」 but not
os lint, which by design does not Zod-parse before running the rules and so is a third, author-facing path that now surfaces the refusal as the command's catch-all.Generated by Claude Code