Skip to content

Retire check-reference-carrier-shape; refuse an unreadable reference carrier at the reader - #18503

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-18095-retire-reference-carrier-shape
Sep 17, 2026
Merged

os-litant merged 4 commits into
mainfrom
claude/issue-18095-retire-reference-carrier-shape

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18095

Executes the maintainer ruling on this card (comment 5699305034, letter E): retire check-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.

reading value control
git grep -l 'refOf(' -- 'packages/' 5 files git grep -l 'FieldSchema' -- 'packages/' = 236 files
git grep -l -E '\.reference\b' over packages/** TS/TSX/MJS 73 files, 151 lines bracket form ['reference'] = 6 lines; same-syntax control ['type'] = 36 files

Both 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 read FieldSchema.reference:

file is it a FieldSchema.reference reader?
packages/lint/src/validate-security-posture.ts ✅ yes — the reader in the #13053 incident
packages/lint/src/data-model-rules.ts ✅ yes — its own docblock says it mirrors the above
packages/lint/scripts/check-reference-carrier-shape.mjs ❌ the gate being retired (prose + one self-test fixture string)
packages/metadata-core/src/contract-suite.ts ❌ name collision — a local refOf = (overrides) => MetaRef building {org, type, name}. Nothing to do with fields
packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts ❌ a comment referring to that MetaRef builder

So refOf( is a good handle on the named instance and a poor census of "every reader". The real census is the 73-file .reference population, 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 arbiter
  • packages/lint/src/validate-security-posture.ts — refOf
  • packages/lint/src/data-model-rules.ts — refOf, plus the R8 options-source read and the R7 summary-target read
  • packages/lint/src/object-graph.ts — graphFieldOf, the slice every other lint rule reads downstream

B — 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:

  • C1, explicit 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.
  • C2, truthiness or equality reads that never answer "no target" (an object value is truthy, and === '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.
  • C3, raw reads that DO still answer undefined silently — the measured residue, 10 sites: objectql/src/engine.ts:13052 and :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:297 and :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 superRefine validators (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 makes safeParse throw 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_COLUMN in service-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:

CONTROL string reference   -> ObjectSchema.safeParse success = true
CLAIM   object reference   -> success = false
  issue: code=invalid_type path=["fields","invoice","reference"]
         message=Invalid input: expected string, received object
CLAIM   array  reference   -> success = false, same path
FieldSchema alone, object  -> success = false ; CONTROL string -> success = true

The premise holds: an object-valued carrier is refused, located, at load. reference is z.string().optional() at packages/spec/src/data/field.zod.ts:1251, and InlineGridColumn'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 Fixes #18095. ⚠️ Corrected at landing: an earlier revision of this body said #13053 「remains open」. It does not — #13053 was closed 2026-08-29 with 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.ts used to assert that reference: { object: 'project' } resolved to nothing and produced relationship/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 bare toThrow(), which an unrepaired reader throwing any Error on any input would satisfy;
  • control: the identical object with reference: 'project' lints clean, so the throw is about the carrier's shape;
  • control: an absent carrier is still the ordinary missing-reference finding, not a throw — absence and unreadability stay different answers.

null, undefined and '' are absence and never throw. That is the retiring gate's own documented position ("null is not a wrong carrier — it is an absent one"), and StrictField declares reference nullable.

What newly throws across the tree

Nothing, measured. The retiring gate's final census, taken on the merge base immediately before deleting it:

check-reference-carrier-shape: OK — 6796 file(s) scanned, 681 `reference` site(s).
  field-def carrier position:  584 string literal(s), 0 non-string literal(s) — 20 unjudged
  provably not a carrier:      20 site(s)
  position unresolved:         45 site(s) + 12 conflicting — 0 material
EXIT 0

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: no check: alias existed — the gate was invoked by path (grep exit 1; control check:doc-security-posture resolves 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):

needle files control
check-reference-carrier-shape 3 check-doc-security-posture = 4
check:reference-carrier-shape 3 check:doc-security-posture = 5

All six survivors are prose, now past-tense and stating the retirement. No roster entry, no workflow line, no package.json alias, no import. Comment-masked counts: check-self-test-wired.mjs 2 in code, dispatch-gates.mjs 1 in code — both are synthetic fixture strings feeding pure-text matchers (no existsSync, no spawn), and they are the cases that now hold the grammar.

⚠️ What this retirement costs, stated rather than buried

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:

packageLocal before: ["packages/lint/scripts/check-reference-carrier-shape.mjs"]   population 214
packageLocal after:  []                                                            population 213
live direct root invocations after: 143 across 80 scripts, all exist  (the control)

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 (packageLocal is exactly the part of population the 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 synthetic battery('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 (no packages/… 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 .reference reads that still answer undefined silently. Routing them means touching objectql, rest, metadata-protocol and verify, 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 asks referenceTargetOf.

Acceptance notes

  • packages/lint/scripts/ keeps two sibling gates (check-doc-formula-expressions, check-doc-security-posture), both invoked as pnpm --filter @objectstack/lint run check:* and therefore outside collectInvocations' population entirely. Pre-existing, not this PR's; recorded so the next reader does not derive it as a wiring gap.
  • The claim comment's declared file surface named six paths; the executed surface is sixteen. The additions are 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.ts and builtin-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

run result
pnpm --filter @objectstack/spec test 483 files, 13775 tests, all pass
pnpm --filter @objectstack/lint test 103 files, 3844 tests, all pass
pnpm --filter @objectstack/cli exec vitest run --project unit test/data-model-rules.test.ts 56 tests pass
pnpm --filter @objectstack/spec check:generated all 15 artifacts up to date (api-surface/ + export-origins/ regenerated: 0 breaking, 1 added)
node scripts/check-self-test-wired.mjs + --self-test 0 / 0 — "213 script(s) … 0 of them package-local gate(s) CI names by path"
node scripts/check-self-test-workflow-commands.mjs --self-test 0 — 8 batteries, 37 cases
node scripts/pm/dispatch-gates.mjs --self-test 0 — 1746 cases pass
21 further derived gates (symbol anchors, step collectors, workflow quoting, published-files, changeset family, nul-bytes, …) all 0

packages/cli's integration tier is declared to CI: the diff touches no spawn entry point. The repo-wide pnpm lint sweep is CI's.

Clause-②

The seat declared yes (claim 5700063579) and hangs needs:contract-review itself. The api-surface reading substantiates it: 1 added, 0 breaking — referenceCarrierOf is a new published export on @objectstack/spec/data. Changeset is minor for @objectstack/spec and @objectstack/lint, as a yes requires. No Clause-② 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_TIER on head 016bdeaa43: PASS — record is comment 5706700015 on this PR.

⚠️ The review named one item as the maintainer's to accept rather than the seat's: whether arbiter + lint routing satisfies ruling E item 2 (「every reader … throw」), given this PR leaves a measured residue of 9 raw reads. The residue is now tracked by #18550, filed BEFORE this PR lands so that Fixes #18095 closing 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:431 reads the GraphField slice and is already covered by the arbiter change), and spec/src/kernel/functional-completeness.ts:167 is 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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-automation/src/builtin/screen-nodes.ts, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/services/service-automation/src/builtin/screen-nodes.ts, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fed4a15ab5b50633553a939e4a9793e5a17ac530 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fe14164448ffa74afaea9f896174162b1d3b695d — the merge of head 016bdeaa43e5398ac163e15e184cf226bff8c6cc into base fed4a15ab5b50633553a939e4a9793e5a17ac530, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

At-tier contract review cannot run right now — the tier itself is refusing, and ⛔ the seat is not downgrading around it

Two 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.

attempt outcome
1st, ~17:50Z killed by this session's own usage limit (HTTP 429 rate_limit) mid-run
2nd, ~19:18Z killed by an account-level limit on the contract-review tier itself — HTTP 429 rate_limit, request id req_011Cf7fBVgG5xo49HoVCRhGV, message: "You've reached your … limit. Switch to another model, or manage usage credits … to continue."

Why there is no lower-tier review instead

references/contract-review.md, the downgrade fuse, L60 — verbatim:

契约复核 ⛔ 不适用额度耗尽豁免降档:豁免对象是派发,复核 ⛔ 不随派发档位免除。

The quota-exhaustion downgrade exemption covers dispatch, never review. And L54 names the state to sit in:

读数不达档 ⇒ 改走转录核验的复核子代理;标签原样留置,队列外等待是安全态。

So: needs:contract-review stays hung on both carriers (this PR and card #18095), this PR stays draft and out of the merge queue, and the wait is the correct state rather than a stall the seat invented. ⛔ No review at a lower tier, ⛔ no seat self-review, ⛔ no landing on the strength of green CI alone — CI is condition ③ of three, and condition ① (an at-tier PASS record naming this head) has not been met.

What is and is not affected

  • The work is not blocked: development continues, and this PR's head 016bdeaa43 has not moved since the review was first dispatched, so when the tier is available the review starts on the same tree rather than a re-derived one.
  • The landing is blocked, and not only here: every PR in this lane carrying a Clause-②: yes declaration needs the same tier before it can land.
  • ⛔ The seat is not re-dispatching in a loop against a limit that says "manage usage credits" — that spends the same refusal repeatedly. It re-dispatches once the tier answers, and reports the refusal to the maintainer rather than working around it.

Recorded here with endpoint and status so the next reader can tell a refusal from a verdict.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Tier re-measured on the seat's hourly check-in — still refusing, and the elapsed time is the new information

One 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:

  • HTTP 429 rate_limit, request id req_011Cf7woTApCE3JPpmPaV7Xz.
  • Previous refusals on this tier: ~19:18Z (request id req_011Cf7fBVgG5xo49HoVCRhGV) and ~17:50Z on the session's own limit.

⇒ ~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: needs:contract-review stays on both carriers, this PR stays draft and out of the queue (references/contract-review.md L54's named safe state), and ⛔ no lower-tier review is substituted (L60: 「豁免对象是派发,复核 ⛔ 不随派发档位免除」).

⚠️ The head has not moved since the first attempt (016bdeaa43), so when the tier answers, the review starts on the same tree rather than a re-derived one — nothing is lost by the wait except time.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 016bdeaa43e5398ac163e15e184cf226bff8c6cc

① Derived judgments

Public surface

  1. @objectstack/spec/data gains one export, referenceCarrierOf(def, reader?) — a WIDENING. Verified on the head tree: src/data/index.ts:145 re-exports field-value.zod, package.json publishes ./data, api-surface/data.json and export-origins/data.json each +1 line / 0 removed. PR characterisation ("1 added, 0 breaking") is RIGHT. This item alone makes the card's yes correct, whatever one thinks of the gate argument.
  2. referenceTargetOf — signature unchanged, behaviour narrowed: a non-string, non-null reference now throws TypeError where it returned undefined. The carrier read now precedes the type check, so a non-reference field type (e.g. text) carrying a malformed reference also throws where it previously returned undefined without looking. The PR's test pins this deliberately; the PR body says only "inherits the refusal". RIGHT in direction, under-stated in reach.
  3. @objectstack/lint (publishes dist): the named exports lintDataModel, validateSecurityPosture, indexObjectGraph and, through graphFieldOf, the seven graph-building validators (validate-dataset-references, -list-view-field-refs, -object-field-refs, -preset-comparands, -rls-predicate-enforceability, -security-posture, -widget-bindings) now throw the same TypeError on such input. No export added, none removed. PR's "6 read sites, 4 files, plus the slice every other rule reads downstream" is RIGHT by class.
  4. Error surface: a new TypeError whose message names the caller, the shape and the fix. No new error code, no new key on any payload, no wire-shape change. RIGHT — the PR does not claim a code and none exists.
  5. Repo CI gate removed: packages/lint/scripts/check-reference-carrier-shape.mjs (584 lines) and its lint.yml step (32 lines). No package.json alias existed (0 hits, control 1). At the head, 7 files still spell the name and all are prose, docblocks or synthetic fixture strings; .github/ has 0 (control 1). This is the ONE genuine LOOSENING in the PR: a non-string LITERAL at a field-def carrier position in a fixture that never reaches a reader is no longer refused by anything on main. It is exactly what the maintainer ruled (letter E, item 1), and the PR's "What newly throws: nothing" is a runtime statement that does not deny it. RIGHT as ruled; named here so the loosening is on record.

Accept set

  1. referenceCarrierOf admits undefined, null and '' as ABSENCE (returns undefined), a string as itself, anything else is refused. null is admitted although FieldSchema.reference is .optional() not .nullable(); the PR cites StrictField, and that holds (solution-blueprint.zod.ts:319 strictIdentOrNull). Every prior reader already treated null as absent, so this is no change of accept set. RIGHT.
  2. os lint (CLI): by its own design comment it does not Zod-parse before running lintDataModel (lint.ts:597) and the rule registry (:669). An author's file with reference: { object: … } therefore used to produce a lint report carrying a wrong relationship/missing-reference row; it now produces the command's catch-all — {error: "data-model-rules refOf: reference is an object, and FieldSchema declares it as an optional STRING …"} exit 1 in --json, printed error exit 1 otherwise. os build, os validate and scaffold-validate exit on the failed safeParse before any rule runs (compile.ts:309–330, validate.ts:279–300, scaffold-validate.ts:76–82) and are unaffected. The changeset's upgrade note names only "a hand-built fixture or a raw registry entry"; this third, author-facing path is not named. Direction RIGHT (ruling item 2 asks for exactly this refusal, and the message carries the fix); reach UNDER-DECLARED. Not a wrong yes/no and not a wrong level; a changeset-text gap.
  3. Class B runtime consumers ($expand, record-title, dangling-reference audit, metadata-protocol/protocol.ts, analytics dimension labels) inherit item 2's throw with no edit. engine.ts has no ObjectSchema parse call of its own, so how far the throw reaches at runtime depends on the loader; the PR concedes raw registry entries and rehydrated rows. RIGHT.
  4. Class C accuracy notes, immaterial to the verdict: C1 is described as "the same silence, spelled differently" but is neither changed nor listed under "does not close"; one of its members, spec/src/kernel/functional-completeness.ts:167, is not silence — it reports a non-string reference as an incompleteness finding. In C3, validate-preset-comparands.ts:431 reads verdict.meta?.reference where meta is the GraphField slice graphFieldOf now builds (object-graph.ts:307), so it is already covered by item 3; the true residue is 9 raw sites, not 10.
  5. LOOKUP_TARGET_COLUMN (screen-nodes.ts) and builtin-node-config.test.ts: 0 non-comment lines changed. Ruling item 3 honoured. RIGHT.
  6. Clause-② declaration test: the card declared yes (claim 5700063579) against the ruling's "no expected". The new export (item 1) makes yes RIGHT on the public-surface axis; the gate retirement (item 5) is a second, ruled ground on the accept-set axis. A no would have been wrong. Both carriers carry needs:contract-review (read on the PR and on the card this run).

② Semver level

Changeset .changeset/18095-retire-reference-carrier-shape-gate.md: @objectstack/spec: minor, @objectstack/lint: minor. CONSISTENT with ①. yes requires at least minor (AGENTS.md L1067) — met on both moved publishers; the new export is a minor-level addition; both packages are in the changesets fixed group. The reader refusal touches only input the published contract already refused at parse, so under the repo's own arm wording ("the accept set a consumer writes against", changeset 18305) no (narrowing) arm and no **BREAKING**/ADR-0087 marker is owed — the precedents that carried them (15110, 16870, 16421) refused shapes the contract previously ACCEPTED at write. Under the stricter observable-behaviour reading (item 7), the launch-window convention still lands on minor (check-changeset-no-major.mjs header), so the level is right under both readings; only the marker question differs, and the repo's definition answers it not owed. Non-blocking: the upgrade sentence should name os lint beside fixtures and raw registry entries.

③ Boundary flags

  1. Dev open_questions — the package-local by-path gate lane is now EMPTY and the three pins that named the retired gate as their live specimen were converted (A accept / B fixture root / C retire the widening). Answer: A is right for this PR. The converted pins hold what is still true (the packageLocal derivation; whole-population quantifiers; a zero with its 143-root-invocation control) and CI exercised all three self-tests on this head (Lint & Repo Gates green; lint.yml:907, :1597, :1640). B is a domain:devx follow-up the seat may file; C is refused as the dev says. Not a contract matter; no escalation.
  2. "What this PR does not close" — the C3 residue (9 raw reads after item 9) plus the 7 C1 sites left unchanged. On the contract axis this is a non-change: nothing there loosens. On ruling execution it is a declared narrowing of item 2's "every reader of FieldSchema.reference found by grep throw", with Fixes #18095 still attached and ruling item 4 closing the card on landing, and no successor card is named for the residue (the dev report's only "to file" entry is a different finding). That acceptance is the maintainer's to give, not the dev's or the seat's: must escalate to the maintainer — either arbiter + lint routing is accepted as satisfying item 2 and the seat files the residue as a follow-up card before the card closes, or the PR extends. It does not change this verdict.
  3. PR body claim "finding(lint): a runtime-gate test fixture spells reference: { object: ... }, a carrier ObjectSchema refuses and the rule's own reader ignores #13053 … is not addressed by this PR and remains open": WRONG on the second half — finding(lint): a runtime-gate test fixture spells reference: { object: ... }, a carrier ObjectSchema refuses and the rule's own reader ignores #13053 is closed (2026-08-29, state_reason: completed). GitHub's own link table confirms the PR does not close it (closed_by_pull_requests empty; control: [finding] check-reference-carrier-shape blind spot for a JSON-Schema properties map and its literal-only predicate — REPORTED, NOT VERIFIED; a reading is owed (half B of #18061) #18095's lists Retire check-reference-carrier-shape; refuse an unreadable reference carrier at the reader #18503), and the only closing keyword is Fixes #18095. Immaterial to the contract; a body-accuracy correction.
  4. Declared file surface grew 6 to 16; every addition is required by ruling item 1 or 2 and the single-writer-path check is green. Answered.
  5. Dev out-of-scope "to file" (check:cross-package-test-inputs reddens locally after a spec build): unrelated to this contract; the seat files it.
  6. Docs drift bot: 3 changed files yielded no anchor (two generated artifacts, one comment-only source file); the tree sweep finds no hand-written page naming the gate. Answered.

Implemented-by: claude/issue-18095-retire-reference-carrier-shape
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Clause-② carriers cleared — provenance

Both carriers stripped in one stroke by the dispatching seat, session_01LvwGppdonww4zGLWZo5rho (domain:spec execution seat), 2026-09-17T00:48Z.

Review of record comment 5706700015 on this PR
Head judged 016bdeaa43e5398ac163e15e184cf226bff8c6cc
Served tier CONTRACT_REVIEW_TIER
Verdict PASS
Carriers cleared PR #18503 ✅ · card #18095 ✅ (both read back after the write)

How this verdict was produced, since the seat is below tier. This seat's measured served model is claude-opus-5; CONTRACT_REVIEW_TIER is claude-fable-5-1 and the comparison is exact ⇒ the seat is not at tier. Per the downgrade fuse it therefore routed the verdict to an isolated review subagent running at CONTRACT_REVIEW_TIER, fed only the card, its rulings and the PR itself, with an adversarial brief — ⛔ never the dispatch order, ⛔ never this seat's conclusions. The record above is adopted verbatim; the seat's only options were verbatim adoption or wholesale voiding. ⛔ This was not an in-seat review and ⛔ not a quota downgrade — references/contract-review.md forbids exempting review from tier.

Independence pair: Implemented-by: claude/issue-18095-retire-reference-carrier-shape (a mode:subagent dev's branch) against Reviewed-by: session_01LvwGppdonww4zGLWZo5rho (the seat adopting the isolated reviewer's verdict). Different kinds ⇒ no SELF-REVIEW.

What the review changed about this PR before it lands:

  1. It measured the body's claim that finding(lint): a runtime-gate test fixture spells reference: { object: ... }, a carrier ObjectSchema refuses and the rule's own reader ignores #13053 「remains open」 as false — finding(lint): a runtime-gate test fixture spells reference: { object: ... }, a carrier ObjectSchema refuses and the rule's own reader ignores #13053 closed 2026-08-29. The body is corrected, because this repo squashes and the body becomes the permanent commit message on main.
  2. It re-measured two counts the body states: the C3 residue is 9 sites, not 10, and one C1 member is misfiled. Both corrections are carried on [finding] 9 raw .reference reads still answer undefined silently after #18503 routed the carrier through one arbiter — the measured residue of ruling E item 2 #18550.
  3. It named one item as the maintainer's to accept, not the seat's: whether arbiter + lint routing satisfies ruling E item 2. [finding] 9 raw .reference reads still answer undefined silently after #18503 routed the carrier through one arbiter — the measured residue of ruling E item 2 #18550 was filed before this PR lands, so Fixes #18095 closing the card cannot lose the residue whichever way that question is answered. ⛔ The seat did not treat landing as the answer.

Pre-landing checks at this head: ① review PASS on record ✅ · ② check-clause2-carriers --pair 18503 exit 0, and it confirms a review of record names this head ✅ · ③ re-taken at landing time, latest-run-per-check-name, all success or an EXPECTED_SKIPS roster skip ✅. Governed-surface predicate: 0 of 16 paths hit the register ⇒ ordinary queue landing, ⛔ no human-merge regime.

⚠️ Note for the record: editing this body re-triggered Check Changeset, so ③ was re-taken again after that run completed rather than trusted from before the edit.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 17, 2026 00:49
@os-litant
os-litant added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit e64ae15 Sep 17, 2026
58 checks passed
@os-litant
os-litant deleted the claude/issue-18095-retire-reference-carrier-shape branch September 17, 2026 07:39
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
os-elon-musk pushed a commit that referenced this pull request Sep 18, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…(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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants