Skip to content

Refuse an unreadable reference carrier at the ten residual readers (ruling E item 2 residue) - #19080

Merged
os-elon-musk merged 5 commits into
mainfrom
claude/issue-18550-reference-carrier-residue
Sep 18, 2026
Merged

os-elon-musk merged 5 commits into
mainfrom
claude/issue-18550-reference-carrier-residue

Conversation

@os-elon-musk

@os-elon-musk os-elon-musk commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18550

Clause-②: yes

Ruling letter E item 2 on #18095 asked for the loud refusal at every reader of FieldSchema.reference. PR #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@0ec81857a:

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

The "four routed lint read sites", re-derived

The card paraphrases PR #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 #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:

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:

anchor    "const ref = referenceCarrierOf(fdef, 'ObjectQL.planCascadeAtomicity');"  x1 -> x0
replace   "const ref = fdef.reference;"                                              x0 -> x1
blob      4ca2839cae6b679c2ae16b2b9e1f804235e7c3b8 -> 6121f5c9bf429db31f959af2e43711506db8baf0
ok mutation landed: anchor 1 -> 0, blob 4ca2839cae6b -> 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  4ca2839cae6b679c2ae16b2b9e1f804235e7c3b8
          blob at HEAD        4ca2839cae6b679c2ae16b2b9e1f804235e7c3b8
          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:

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:

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

✓ 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 #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 (#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 validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #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 #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 #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

…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>
…uted reader

Every refusal case has an absence partner (`undefined` / `null` / `''`) and a
positive control, so a harness that had stopped exercising the reader could
not pass vacuously. The objectql suite also pins that nothing is written when
the refusal fires, which is the measured defect inverted.

The four lint sites take the form the already-routed lint readers use: the
literal `.reference` read stays at the site so the #5017 receiver meta-test
keeps its subject, and only the shape judgment moves to the arbiter.

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
… presence

`check:objectql-double-limit` graded the new file's `find` double limit-blind.
Fixed at the double — the baseline never grows.

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
… list

`relationTarget`'s subject is WHICH KEY it reads, stated at length in its own
docblock, so the read stays greppable in the function body and only the shape
judgment moves to the arbiter — the same form the lint readers use.

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/rest, @objectstack/verify, touching 9 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — 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 — 38 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 176b03582e600ee5628d21bff9422073c5a5530c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 90bbbf7ebb4d2a43276dad5f3d11b6f058f031c9 — the merge of head a17094d9482575b014edce04224a0426e4e93712 into base 176b03582e600ee5628d21bff9422073c5a5530c, 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 90bbbf7ebb4d2a43276dad5f3d11b6f058f031c9 && git checkout 90bbbf7ebb4d2a43276dad5f3d11b6f058f031c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 176b03582e600ee5628d21bff9422073c5a5530c a17094d9482575b014edce04224a0426e4e93712 && git checkout -B drift-repro 176b03582e600ee5628d21bff9422073c5a5530c && git merge --no-ff a17094d9482575b014edce04224a0426e4e93712

node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 176b03582e600ee5628d21bff9422073c5a5530c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
…n the page

The route's error table enumerated four outcomes and now has a fifth: a stored
`reference` holding a non-string declares a target the route cannot read, and
answers the sanitised fault rather than LOOKUP_TARGET_MISSING. The page said
"could not be resolved from the field definition" for the missing-target code,
which read as covering both — the distinction this routing exists to make.

Found by hand, not by the drift bot: `rest-server.ts` yields no doc anchor, so
pages documenting it are invisible to that run by its own declaration.

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

TypeScript Type Check failure is a SUPERSESSION ARTIFACT on a dead head — ⛔ not re-run, and ⛔ not read as a verdict. Seat: domain:spec#3 · taken 2026-09-18T16:44Z

The event names head bd1b3dcad973e643f6b1226805fa71ad856745df. That head was superseded by a push; the live head is a17094d9482575b014edce04224a0426e4e93712.

Measured, ⛔ not pattern-matched

reading value
run 105681994591 duration 3 seconds, empty output, failure
dead head's check-runs 18 success · 6 skipped · 13 cancelled · 1 failure
of those cancelled, the aggregator's own member lanes Type Check · workspace and Type Check · consumer gates
live head a17094d948, four Type Check · * lanes all in_progress; zero non-green across the head

⇒ the push cancelled the in-flight run and the aggregator turned its cancelled members into a failure. A type check does not complete in three seconds; an aggregator noticing cancelled members does. The latest run bound to the CURRENT head is the verdict, ⛔ never the event's sha.

One difference from the sibling case, stated because it weakens the reassurance

On PR #19076 the same artifact appeared and the superseding commit changed no TypeScript at all, so a genuine type error could not have hidden behind the supersession. ⛔ That argument is not available here: this push changed code (17 → 18 files). So the only support for 「artifact」 is the cancelled-member evidence and the three-second duration — and the actual verdict is whatever the four Type Check · * lanes on a17094d948 conclude.

⇒ if a real type failure exists it will appear there, and it will be this PR's to fix. This comment is not a claim that the PR is green.

⛔ No re-run of the dead head's run and no push: re-running a cancelled lane to tidy a display is what the standing rule forbids, and there is nothing yet to fix. The implementing round is still open and the seat is watching the live head.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Head reviewed: a17094d9482575b014edce04224a0426e4e93712

Reviewed-by: isolated at-tier reviewer commissioned by domain:spec seat 3, session session_019srGWGCBBCBHqcDoRZpQRh · ⛔ not the commissioning seat, which measured itself below tier and may not judge a clause-② verdict
Served-tier: 6/6 CONTRACT_REVIEW_TIER

Tier established by the reviewer as its first act, before any review work, by grepping its own transcript: 6 "model":"claude-fable-5-1" across 6 assistant turns (control: 8/8 identical over all lines), compared against the constant at scripts/pm/dispatch-gates.mjs:11899. ⚠️ That method is the requirement: a subagent's get_session reads the PARENT session's model, so self-attestation through it is not evidence about the subagent.

Verdict: CLAUSE-② PASS. All five blocking items pass. Reviewed in the reviewer's own worktree at the head above; origin/main read only through git show/git grep origin/main:; the shared checkout was never edited.

# item verdict the reading that settled it
1 accept-set movement PASS field.zod.ts declares reference: z.string().optional(); action.zod.ts SnakeCaseIdentifierSchema.optional(); git diff -- packages/spec empty. Arbiter probed with tsx: {} / undefined / null / '' / non-object def → undefined; object / array / number / boolean → TypeError. No published accept set moves — a shape no schema ever admitted now refuses at ten reads
2 absence never throws PASS all ten sites read at this head; pins run: objectql 6/6, rest 23/23, metadata-protocol 7/7, lint 436/436, verify 18/18. Reviewer's own two ablations: revert derive.ts → exactly 2 carrier pins red / 16 green; revert rest-server.ts → exactly 1 red / 22 green; both restored, blob-hash verified
3 refusal leaves state untouched PASS read verbatim: planCascadeAtomicity precedes runByIdDelete, both iterating the same registry snapshot ⇒ the refusal fires before any row is touched. Seam 2 is reachable only if the registry changes between reads, which the test simulates, and in the atomic plan it sits inside transaction(…)
4 published error surface PASS no error code, field or id added. The escaping TypeError maps through the existing mapDataError fallback to a sanitised 500 INTERNAL_ERROR and is logged; absence stays 500 LOOKUP_TARGET_MISSING. ⚠️ Pre-change, an object carrier was not LOOKUP_TARGET_MISSING either — it was truthy and went into findData — so the wire moves from a downstream fault to a sanitised 500 before the search
5 changeset level PASS as declared minor on five public lockstep packages; level axis run offline with the real declaration → exit 0, and CI Check Changeset green. ⚠️ Finding: the act itself grades patch under pr-automation.yml's own prose, and minor here is derived from the declaration, not from the change; the #18503 precedent cited for it added a new published export, which this PR does not
6 no gate weakened PASS the 18-path diff contains 0 baseline / ledger / ratchet / debt paths — control: 131 such paths exist on main. objectql-double-limit.baseline.json diff empty (control: 838 lines; it does not name the new test). Added .skip / .only / @ts-expect-error lines 0 (control: such lines do exist elsewhere at this head). The one red gate was fixed in the test double (+5/−1, the double's find now applies ast.limit); the two exit-3 gates were satisfied by building, never by a ledger edit
7 the eleventh line PASS, defensible; card owed engine.ts around buildSummaryIndex reads the carrier by equality and silently skips — byte-identical to the line PR #18503 filed under C2, so that class's premise 「equality reads never answer no-target」 is false there. Excluded here for two reasons: outside this card's ten-line list, and that function runs inside the #9154 try/catch, so routing it would turn one field's silence into the whole index refusing to build. ⇒ filed as #19082; ruling E item 2 stays open, which the PR says itself
8 mis-cited refusal text PASS, non-blocking the ActionParam site's message names FieldSchema, and the claim is true there (ActionParamSchema.reference is a string, and the spec docblock declares the mirror). ⚠️ But the reader label at that site could have named the real schema with zero spec edits, inside this card's surface — recorded as a follow-up
9 the doc edit PASS two cells on content/docs/ui/forms.mdx, accurate against code and pin. content/docs/releases/ diff → 0 paths (controls: 1 content/docs path in the diff; 10 entries in that directory on main) ⇒ the 7 release-owned pages are byte-untouched, as a code PR requires
10 declaration correctness FINDING against the seat the criterion is 「放宽接受集或扩大公开面」 (SKILL.md:477), and 「拉回已声明契约不触它」 (:515, and lanes/spec.md:19-20). This PR does neither ⇒ no was the criterion-accurate reading, and the seat's stated ground 「observable runtime behaviour moves」 is not the criterion. ⛔ Not a false declaration — 「按 yes 申报恒不是错误」, and a wrong no is the auditable fault — but it cost this review and a minor forced where the act grades patch

Disposition of the carrier

Both carriers were cleared seconds apart after this record was written, and this comment is the record the clear cites. The declaration stands as yes: retracting it would discard a completed at-tier PASS, clear and re-hang a gate on two objects, re-grade the changeset and spend a fresh 42-check cycle on five published packages, to save one semver notch. ⇒ what is owed is that the reason not be reused — ⛔ a future seat must not read 「runtime behaviour moves」 as clause ②.

Findings 5, 7, 8 and 10 are non-blocking and are weighed on the card (#18550, comment 5733563414), together with three wording nits the seat deliberately did not push, because a push moves the head this verdict is bound to: the REST pin has no '' absence control (the arbiter handles it identically — coverage, not behaviour), and the new doc row, the changeset and the log undersell the refused set (number and boolean refuse too; the log names the carrier's shape, not its value).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 6/6 CONTRACT_REVIEW_TIER
Head-sha: a17094d9482575b014edce04224a0426e4e93712

Supersedes comment 5733580409, which carried the same verdict and the same readings but failed the record contract twice: the head was under a key of this seat's own invention (「Head reviewed:」 rather than Head-sha:), and the authorship pair was HALF WRITTEN — no Implemented-by: at all, and a Reviewed-by: whose first token after the colon was prose naming the reviewing model, which 「compares to nothing」. Caught by check-clause2-carriers --pair 19080 (C6 then C4), ⛔ not by this seat. The remedy the tool prints is the one that was owed from the start: --template spells both lines, so COPY it rather than composing one. This record is copied from it.

① Derived judgments

Ten items reviewed by an isolated at-tier reviewer in its own worktree at the head above; origin/main read only through git show / git grep origin/main:; the shared checkout never edited. Tier established as the reviewer's first act by grepping its own transcript (6 "model":"claude-fable-5-1" over 6 assistant turns, control 8/8 over all lines) against the constant at scripts/pm/dispatch-gates.mjs:11899 — ⚠️ the required method, because a subagent's get_session reads the PARENT session's model and so attests nothing about the subagent.

Blocking items, all PASS:

  1. Accept-set movement — field.zod.ts declares reference: z.string().optional(), action.zod.ts SnakeCaseIdentifierSchema.optional(), and git diff -- packages/spec is empty. The arbiter probed with tsx: absent / null / '' / non-object def answer undefined; object, array, number and boolean refuse. No published accept set moves — a shape no schema ever admitted now refuses at ten reads.
  2. Absence never throws — all ten sites read at this head; pins run: objectql 6/6, rest 23/23, metadata-protocol 7/7, lint 436/436, verify 18/18. The reviewer's own two ablations: reverting derive.ts reddens exactly 2 carrier pins of 18, reverting rest-server.ts exactly 1 of 23; both restored, blob-hash verified.
  3. Refusal leaves state untouched — planCascadeAtomicity precedes runByIdDelete, both iterating one registry snapshot, so the refusal fires before any row is touched; the second seam is reachable only under a registry change between reads and sits inside transaction(…) in the atomic plan.
  4. No gate weakened — the 18-path diff contains 0 baseline / ledger / ratchet / debt paths, against a control of 131 such paths on main; the objectql-double-limit baseline diff is empty (control: 838 lines, and it does not name the new test); added .skip / .only / @ts-expect-error lines 0, with a control proving such lines exist elsewhere at this head. The one red gate was fixed in the test double; the two exit-3 gates were satisfied by building, never by a ledger edit.
  5. The doc edit — two cells on content/docs/ui/forms.mdx, accurate against code and pin; the content/docs/releases/ diff is 0 paths (controls: 1 content/docs path in the diff, 10 entries in that directory on main), so the 7 release-owned pages are byte-untouched as a code PR requires.

Non-blocking findings: item 4 (no error code added; an existing sanitised 500 INTERNAL_ERROR now answers a new input, and pre-change an object carrier was not LOOKUP_TARGET_MISSING either) · item 7 (the eleventh reader, left out defensibly and filed as #19082) · item 8 (the ActionParam refusal text names FieldSchema; true there, but the real schema was nameable with zero spec edits) · item 10, below.

② Semver level

minor on five public lockstep packages: PASS as declared — the level axis was run offline with the real declaration and exits 0, and CI Check Changeset is green. ⚠️ Finding: the act itself grades patch under the workflow's own prose, and this minor is derived from the declaration rather than from the change; the PR #18503 precedent cited for it added a new published export, which this PR does not.

③ Boundary flags

No security or permission boundary moves. No published contract surface is widened, no export, key or error code is added, and content/docs/releases/ is untouched. Ruling E item 2 (「every reader」) stays open after this PR, by the PR's own statement and the reviewer's item 7.

⚠️ Item 10, the finding against the commissioning seat: the clause-② criterion is 「放宽接受集或扩大公开面」 (SKILL.md:477), and 「拉回已声明契约不触它」 (:515, with lanes/spec.md:19-20). This PR does neither, so no was the criterion-accurate reading, and the seat's stated ground 「observable runtime behaviour moves」 is not the criterion. ⛔ Not a false declaration — 「按 yes 申报恒不是错误」 and a wrong no is the auditable fault — but it cost this review and the forced minor above. The declaration stands; what is owed is that the reason not be reused.

Implemented-by: claude/issue-18550-reference-carrier-residue
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

VERDICT: PASS


Generated by Claude Code

@os-elon-musk
os-elon-musk marked this pull request as ready for review September 18, 2026 17:19
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit a675ad4 Sep 18, 2026
54 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-18550-reference-carrier-residue branch September 18, 2026 21:52
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

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants