Skip to content

fix(plugin-audit): resolve a summary reference field's target through referenceTargetOf (#19264) - #19285

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-19264-audit-implicit-reference-target
Sep 20, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
claude/issue-19264-audit-implicit-reference-target

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #19264

Clause-②: no

The defect

packages/plugins/plugin-audit/src/audit-writers.ts admits user as a reference type (REFERENCE_FIELD_TYPES) and then required the materialized reference carrier, so a trackHistory'd { type: 'user' } field authored without an explicit reference was planned for nobody and the timeline rendered the raw id.

The contract says the opposite for exactly that type. IMPLICIT_REFERENCE_TARGETS (packages/spec/src/data/field-value.zod.ts), re-read verbatim on origin/main at 215840f43 before this branch was cut:

user is the only member: field.zod defines it as "a lookup specialized to the sys_user system object … target fixed to the sys_user system object", and the Field.user() builder — unlike Field.lookup(reference, …) / Field.masterDetail(reference, …) — takes NO target argument and writes reference: 'sys_user' itself. The target is a CONSTANT OF THE TYPE, so reference on a user field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is fully specified, not under-specified.

So the one spelling the contract calls complete was the one the reader refused, and it refused it silently: no refusal, no diagnostic, the field simply absent from the read plan. referenceTargetOf lives in that same module to supply the constant, and is the arbiter plugin-approvals moved to in PR #19263 / 841a71e51.

FOUR sites, not two — the card's site count is falsified

The card and the dispatch order both name two carrier-gated sites, the two read planners. Re-measured on this branch's own base rather than trusted, the carrier was read at four:

line on 215840f43 function role
:472 planTrackedLookupReads builds the read plan, keyed by target object
:527 renderTrackedChangeSummary looks the resolved titles back up under the same key
:629 planMilestoneTokenReads builds the milestone read plan, keyed by target object
:693 renderMilestoneSummary looks the resolved titles back up under the same key

The target is the KEY of the objectName -> (id -> title) map, so it has two ends. Repairing only the two planners issues the read and then looks the result up under a key the plan never used — the timeline still shows the id, and now pays for a read it cannot use. That is measured below, not argued: the renderer-only ablation leaves every read-count assertion green and every rendered-string assertion red.

Everything else the card states holds as given: the line ranges :471-473 and :628-630, and the reading that :434 in displayFieldValue does not gate on the carrier and is the consumer where the symptom surfaces rather than a defect site.

The repair

One module-private helper, referenceTargetForSummary, asks referenceTargetOf, and all four sites use it.

  • Nothing widens. REFERENCE_FIELD_TYPES is untouched, so the admitted types stay lookup / master_detail / user. tree is deliberately still out: it carries an author-chosen target, so admitting it would widen what the timeline tracks rather than repair what it silently dropped. A lookup / master_detail whose author-chosen target is absent still names nothing and still issues no read.
  • A padded carrier can no longer split the key. The planners used to trim() and the renderers did not, so reference: ' crm_account ' produced two keys and no title. One helper trims once, for both ends.
  • The unreadable-carrier behaviour is unchanged, and the throw is caught deliberately. referenceTargetOf reads the carrier through referenceCarrierOf, which throws for an object- or array-valued reference. All four call sites run inside writeAudit's summary composition, which is not inside the try that guards the audit row write — measured: between writeAudit's first line and its end there is exactly one try, and it wraps persistAuditTrailRow alone. An escaping TypeError would therefore leave the hook, turning a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out of the plan exactly as typeof field.reference === 'string' already left it out: byte-identical behaviour for that class, not a new silence.
  • No authoring change. Metadata that already spells reference: 'sys_user' resolves to the same target it always did.

Tests — silence is the defect, so the read and the rendering are pinned separately

New packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts, 10 cases against the counting driver the sibling summary suites already use (reads counted, rows copied).

Every implicit-target case asserts both halves: the sys_user read delta (the half a happy-value assertion cannot see — before this change that read was never issued at all) and the rendered summary string (the half a read count cannot see — a plan-only repair pays for the read and still prints the id). The target is asserted through referenceTargetOf({ type: 'user' }), never a hand-copied 'sys_user', so a second de-facto constant in this package would not satisfy it.

Both planner sites are covered: the tracked-change branch through a tracked user field with no reference, and the milestone branch through a milestone token on an untracked user field with no reference (that branch is deliberately not gated on trackHistory).

Four narrowness controls, so "the implicit target resolves" cannot be read as "everything resolves now": a tracked targetless lookup (as a diff row and as a milestone token) stays unresolved and costs nothing; a tracked tree with a perfectly readable target stays out of the type gate and renders its raw id; and a write that moves no reference field, on a row that HOLDS one, still pays zero reads.

The second commit is that file's find double, not the subject: see Gates below.

Reverse verification — two ablations, both through scripts/ablation-replace.mjs

Re-run from the final committed state at 7ecf511d2, after the test file's last edit. The subject is resolved from SOURCE, not from dist: the test imports ./audit-writers.js, a relative specifier inside the package, so vitest transforms the mutated file directly and no build sits between the mutation and the run.

Leg 1 — revert BOTH planner sites (anchor hit 2 times as declared, blob 9123759bc8e2 to f6b9e89ac1a7):

Test Files  1 failed (1)
     Tests  3 failed | 7 passed (10)

The three implicit-target cases go red on the READ: expected +0 to be 1, three times. The plan is empty again, exactly as before the repair.

Leg 2 — revert BOTH renderer sites, leaving both planners repaired (anchor hit 2 times as declared, blob 9123759bc8e2 to 90516141da24). This is the leg that measures the card's two-site reading:

Test Files  1 failed (1)
     Tests  4 failed | 6 passed (10)

Every read-count assertion stays green and every rendered string goes red — expected 'Owner: ∅ → usr_1' to be 'Owner: ∅ → Grace Hopper' and expected 'Deal won by usr_1' to be 'Deal won by Grace Hopper'. The reads are issued and thrown away. That is the reported symptom, unrepaired, at a higher read cost.

Both legs restored and proven restored by the tool: blob back to 9123759bc8e2, identical to HEAD, git diff HEAD empty.

The census triage asked for

whoever takes either should sweep for a third: a contract that two consumers read the same wrong way is a contract whose consumers need a census, not two point fixes.

Corpus: every referenceCarrierOf and referenceTargetOf call site in the repo's package sources, plus every raw field.reference read, at 7ecf511d2. Positive control on the same corpus: referenceTargetOf is a real, importable symbol with call sites in metadata-protocol/src/protocol.ts, objectql/src/engine.ts, objectql/src/integrity/dangling-reference-audit.ts, objectql/src/record-title.ts, plugin-approvals/src/approval-service.ts and service-analytics/src/dimension-labels.ts — so a zero below is a reading.

A methodology note that is itself a finding. The first pass of this sweep used the pathspec packages/**/src/**/*.ts, which under git's default (non-:(glob)) matching does not reach packages/plugins/plugin-audit/src/audit-writers.ts — the very file this card is about. It returned a clean five hits. What caught it was a control: grepping for a string the defect file is known to contain and finding the file absent from the result. Every count below was re-taken without that pathspec.

One third site, same silent shape — reported, deliberately not fixed (outside this card's file surface):

  • packages/metadata-protocol/src/seed-loader.ts:712, SeedLoader.buildDependencyGraph. The type gate is lookup || master_detail || user — user admitted — and the next line reads the carrier and continues when it is absent. A seed row whose { type: 'user' } field carries no reference therefore contributes no dependsOn edge and is never pushed onto references, so its natural key is written verbatim instead of being resolved to a record id. That consequence is named in this same function's own docblock, for the case it does handle: "every lookup / master_detail seed value was written verbatim … Dangling references break parent joins." Silent, user-visible, same misread contract. Dedupe words: seed-loader user implicit reference target · buildDependencyGraph referenceCarrierOf user · seed natural key written verbatim user field · metadata-protocol implicit target census.

One near-site of a different shape — LOUD, reported for judgment:

  • packages/rest/src/rest-server.ts:10914, the public-form lookup picker. It has no type gate at all (any field the form opts in with publicPicker), reads the carrier, and answers 500 LOOKUP_TARGET_MISSING when it is absent — so a publicPicker on a { type: 'user' } field authored without reference is refused rather than silently dropped. Same misreading, opposite failure mode. PUBLIC_FORM_SERVER_MANAGED_FIELDS already refuses server-managed anchors on this surface, so the reachable case is a hand-authored user field. Dedupe words: public form lookup picker user implicit target · LOOKUP_TARGET_MISSING user field · rest picker referenceCarrierOf implicit.

The card's two unverified analytics readings — both CONFIRMED:

  • service-analytics/src/dimension-labels.ts:142 already delegates to referenceTargetOf. Correct today; no action.
  • service-analytics/src/plugin.ts:748 (relationshipResolver) gates on lookup || master_detail, so user never enters the gate at all — materialized carrier or not — and falls through to return engine ? undefined : relationshipName. With an engine present that undefined makes the dataset compiler reject the relationship. LOUD, and a different shape: a type-gate exclusion, not an implicit-target misread. No action.

Cleared, each with its reason — every other carrier consumer in the corpus:

  • Type gate excludes user, so the implicit target cannot arise: cli/src/commands/doctor.ts:726 and :930 (lookup only), objectql/src/engine.ts:13113 and :13568 (cascade; master_detail / lookup), lint/src/validate-expressions.ts:395 and lint/src/validate-sharing-rule-enforceability.ts:267 (master_detail only), verify/src/derive.ts:148 (its RELATIONAL set is the lookup / master_detail spellings plus tree), and spec/src/kernel/functional-completeness.ts:177 — whose RELATIONSHIP_TYPES is lookup / master_detail, so field/relationship-without-reference does not fire on a spec-complete user field.
  • Reads the carrier as the AUTHOR'S DECLARED KEY, which is their actual subject rather than "what does this field point at": the five generic lint readers (data-model-rules.ts:250, object-graph.ts:242, validate-field-consumers.ts:560, validate-object-references.ts:306, validate-security-posture.ts:292).

Gates

Every exit code below was captured before any pipe. The derived set was re-derived at the final head 7ecf511d2 and is byte-identical to the one derived at dce04112d.

  • pnpm --filter @objectstack/plugin-audit test — exit 0, 25 files / 363 tests passed, at the final head.
  • pnpm --filter @objectstack/plugin-audit typecheck — exit 0. The leg count was read from the package, not assumed: its typecheck script is tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && pnpm check:test-typecheck, three legs, all run. The test-layer ledger is unmoved — 0 file(s) / 0 error(s) / 0 pinned signature(s) — and this package carries no test-typecheck-debt.json of its own, so nothing was touched there.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 61 families from a change set of 3 paths (three-dot semantics, merge base 215840f43), 542 changed lines, under the 5,000 human-merge threshold. All 61 were run.
  • One family went genuinely RED and was repaired, not baselined. pnpm check:objectql-double-limit exit 1: the new test file's counting find double was UNJUDGED, because the gate lifts the double and drives it with a row stub in place of every non-function declaration, and (reads.findOn[object] ?? 0) + 1 throws on that stub before the body can be judged. The double now reads its counter by TYPE and applies the caller's limit by PRESENCE, after the filter and before the rows are copied. Re-run: exit 0, and the census moved the right way — 393 graded (was 392), 195 applying the bound (was 194), 39 proving the bound precedes a row-touching stage (was 38), 55 unjudged (was 56), with baseline key set verified against 215840f: no files added.
  • Three families first answered exit 3 = PREREQUISITE NOT MET = NOT MEASURED: check:dual-build-cjs-loads, check:i18n and check:type-check-debt. Each named the same prerequisite ("this gate reads built output, and some package has no dist/ … Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"). The prerequisite was cleared with a whole-tree pnpm build (exit 0, 73/73 tasks) and each re-ran to exit 0. None is reported as a zero.
  • The remaining 57 families ran to exit 0 on the first pass. After the second commit, every family whose subject is the changed test file was re-run at the final head — check:objectql-double-limit, check:type-check-debt, check:type-check-coverage, check:test-source-alias, check:cross-package-test-inputs, check:nul-bytes, check:tier-file-adoption, check:engine-double-contract, check:driver-memory-census, check:where-matcher, check:published-files, check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check-closing-keyword-parity — all exit 0.
  • pnpm lint (eslint . --no-inline-config, the repo's only style authority) — exit 0 over the whole tree, so no narrowing is being claimed and no narrowing evidence is owed.
  • grep os-regen .gitattributes, read on the spot: every merge-driver-managed path is under packages/spec/, docs/, content/docs/, skills/ or scripts/platform-object-tenancy-census.json. No path in this diff is driver-managed.
  • A control-byte self-scan beyond check:nul-bytes, over all three changed files: grep -naP for the C0 range plus DEL — no hits.
  • Changeset: decided by reading, not habit. packages/plugins/plugin-audit/package.json carries no private key and ships files: ["dist", "README.md", "CHANGELOG.md"], so a fix here publishes. A patch changeset is included.

Verdict lines transcribed verbatim, because those logs die with the worktree

From pnpm check:engine-double-contract (exit 0):

UNRECOGNISED [engine-double-contract]: 9 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 213 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (#9747, ruling of 2026-08-18).

From node scripts/check-changeset-no-major.mjs --base origin/main (exit 0):

ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from, so the clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: the RC cut (`cut-rc.yml`) and a local run reach here, and neither is a PR that could have declared.
   · no `pull_request` payload was available to read a declaration from

That second one is a local-run artefact by construction: on this PR the axis has its input, and the declaration it will read is the Clause-②: no line at the top of this body.

Not this PR

packages/spec is read-only on this lane: referenceTargetOf is imported and nothing in that package is edited. No release-notes page is touched. plugin-approvals was repaired in PR #19263 and is out of scope here. The two census findings above are reported for the seat to file and deliberately not fixed here — that is the difference between two bugs and one systemic gap.

Acceptance notes

  • noted, not filed — the sibling summary suites (audit-lookup-summary.test.ts, audit-milestone-summary.test.ts, audit-option-label-summary.test.ts, audit-bound-previous.test.ts) each hold the same counting driver in its pre-repair, UNJUDGED shape and are grandfathered in scripts/objectql-double-limit.baseline.json. The one-line change made here would shrink that ledger by four entries. Not done in this PR: it is unrelated to the card and would put four files this card has no business in on the diff. Carrier: any later PR editing those suites.
  • noted, not filed — renderTrackedChangeSummary and renderMilestoneSummary both build an optionLabelFor closure per field per write. Ordinary allocation on a hot path; no defect, no repro, no contract text. Carrier: none — no open PR or queued card touches those two functions.
  • noted, not filed — referenceTargetOf answers a whitespace-only carrier (reference: ' ') with that whitespace rather than falling through to the type's constant, because referenceCarrierOf treats only the empty string as absence. The helper added here trims afterwards, so the audit path's behaviour is unchanged in both directions; making the arbiter itself trim is a packages/spec question and this lane is read-only there. Carrier: none observed.

Generated by Claude Code

… `referenceTargetOf` (#19264)

`audit-writers.ts` admitted `user` as a reference type and then required the
materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }`
field authored without one was planned for nobody and the timeline rendered
the raw id — silently, with no refusal and no diagnostic.

`IMPLICIT_REFERENCE_TARGETS` declares the opposite for exactly that type: the
target is a CONSTANT OF THE TYPE, and metadata authored without it is fully
specified. `referenceTargetOf` exists to supply it, and is the same arbiter
`plugin-approvals` moved to in #19263.

The carrier was read at FOUR sites, not two: the two read planners key the
plan by target object and the two renderers look the resolved titles back up
under the same key, so a plan-only repair would issue the read and still print
the id. One helper answers for all four.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
…e caller's bound

`check:objectql-double-limit` graded the new counting driver's `find` as
UNJUDGED: the gate lifts the double and drives it with a row stub in place of
every non-function declaration it cannot supply, and `(reads.findOn[object] ??
0) + 1` throws on that stub before the body can be judged at all. Read the
counter by TYPE instead, apply the caller's `limit` by PRESENCE after the
filter and before the rows are copied.

Graded rather than baselined: the family now reports 393 doubles graded (was
392), 195 applying the bound (was 194), 39 proving the bound precedes a
row-touching stage (was 38), and `no files added` to
`scripts/objectql-double-limit.baseline.json`.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 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 15f92842ffca0be5d24e4778698c711310a984ee → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 15f92842ffca0be5d24e4778698c711310a984ee

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

@huangyiirene
huangyiirene marked this pull request as ready for review September 20, 2026 10:09
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 5636641 Sep 20, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19264-audit-implicit-reference-target branch September 20, 2026 10:31
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ur repaired through `referenceTargetOf` (objectstack-ai#19289) (objectstack-ai#19472)

Fixes objectstack-ai#19289

Clause-②: no

`IMPLICIT_REFERENCE_TARGETS`
(`packages/spec/src/data/field-value.zod.ts`) declares a `user` field's
target "a **CONSTANT OF THE TYPE**" and metadata authored without
`reference` "**fully specified, not under-specified**". Two arbiters
answer two different questions — `referenceCarrierOf` what the carrier
says, `referenceTargetOf` what the field points at — and for `user` only
the second matches that text. objectstack-ai#18550 standardised a population of
readers on the first. This is the census of that population.

**The arbiters are NOT edited.**
`packages/spec/src/data/field-value.zod.ts` is untouched; the diff is
`packages/lint`, `packages/metadata-protocol`, `packages/rest` and their
tests.

## The census was re-measured, and it had moved

Taken on `8f6d831` with **no pathspec**, excluding tests and the
arbiters' own module: **17 `referenceCarrierOf` vs 8
`referenceTargetOf`** — ⛔ not the card's 16 vs 7. Class (A) is **8**
(card: 7); class (B) is **9** (unchanged). Both deltas landed *after*
the card's census:

| delta | commit | landed |
|---|---|---|
| `objectql/src/engine.ts:9077` — a NEW class-(A) carrier site
(`buildSummaryIndex`) | `875e9ad` (objectstack-ai#19293) | 2026-09-20T10:37Z |
| `plugin-audit/src/audit-writers.ts:429` — raw read → target, so the
target count rose | `5636641` (objectstack-ai#19264 / PR objectstack-ai#19285) | 2026-09-20T10:09Z |

The `objectql/engine.ts` line numbers also drifted (13113 → 13204, 13568
→ 13659). The card's instrument trap reproduces on this tree, with
`REFERENCE_FIELD_TYPES` as the known-present needle: bare
`packages/**/src/**/*.ts` → **0 files**, `:(glob)…` → **2**, no pathspec
→ **2**.

## Per-site verdict — all 17

The deciding question is each site's **own type gate**: where it
excludes `user`, the carrier *is* the target for the question that site
asks, and the site stays on the carrier.

### Class (A) — handed a real field definition (8)

| # | site | own type gate | verdict |
|---|---|---|---|
| A1 | `metadata-protocol/src/seed-loader.ts:712` | `lookup \|
master_detail \| user` — **admits** | 🔴 **DEFECT ① (silent)** — repaired
|
| A2 | `rest/src/rest-server.ts:10914` | **none** — any field the picker
names | 🔴 **DEFECT ② (loud, 500)** — repaired |
| A3 | `cli/src/commands/doctor.ts:726` | `lookup` only | ✅ not a defect
— type-gate exclusion |
| A4 | `cli/src/commands/doctor.ts:930` | `lookup` only | ✅ not a defect
— type-gate exclusion |
| A5 | `objectql/src/engine.ts:9077` *(new)* | `master_detail \| lookup`
| ✅ not a defect — type-gate exclusion |
| A6 | `objectql/src/engine.ts:13204` | `master_detail \| lookup` | ✅
not a defect — type-gate exclusion |
| A7 | `objectql/src/engine.ts:13659` | `master_detail \| lookup` | ✅
not a defect — type-gate exclusion |
| A8 | `services/service-analytics/src/plugin.ts:748` | `lookup \|
master_detail` | ✅ not a defect — pre-judged on the card |

### Class (B) — synthesize `{ reference: … }`, discarding `type` (9)

| # | site | own type gate | verdict |
|---|---|---|---|
| B1 | `lint/src/data-model-rules.ts:250` (`refOf`) | 4 callers:
`RELATIONSHIP_TYPES` = `{lookup, master_detail}`, `OPTION_FIELD_TYPES`,
`summary` | ✅ not a defect |
| B2 | `lint/src/object-graph.ts:242` (`graphFieldOf`) | **none**;
consumer `RELATIONSHIP_FIELD_TYPES` **admits `user`** | 🔴 **DEFECT ③
(silent, widest)** — repaired |
| B3 | `lint/src/validate-expressions.ts:395` | `master_detail` only | ✅
not a defect |
| B4 | `lint/src/validate-field-consumers.ts:560` | **none** | 🔴
**DEFECT ④ (silent)** — repaired |
| B5 | `lint/src/validate-object-references.ts:306` |
`RELATIONSHIP_TARGET_FIELD_TYPES` **admits `user`** | 🟡 latent misread —
aligned, no output change |
| B6 | `lint/src/validate-object-references.ts:329` (action param) |
`ActionParamSchema.type` is **optional** | ✅ not a defect —
**measured**, see below |
| B7 | `lint/src/validate-security-posture.ts:292` (`refOf`) |
`CBP_TIERS` = master_detail / lookup | ✅ not a defect |
| B8 | `lint/src/validate-sharing-rule-enforceability.ts:267` |
`master_detail` only | ✅ not a defect |
| B9 | `verify/src/derive.ts:148` | `RELATIONAL` = lookup /
master_detail / tree — **excludes `user`** | ✅ not a defect |

⛔ **Class (B) was smaller than the card's framing, not larger.** Seven
of the nine are type-gated away from `user`; only B2 and B4 needed the
pass-the-field-through repair. Three of the nine (B3, B7, B9)
additionally keep their synthesized `{ reference: x.reference }` literal
because the **objectstack-ai#5017 receiver meta-test reads their source** to prove
they read `reference` and never an alias — folding that read into a
helper call would disarm that scan silently. Those three are untouched.

## B6 — the site the tests refused, and why it is in the table as judged

I initially swapped the action-param site too.
`reference-integrity-suite.test.ts` went red: `object-reference-unknown`
vanished from the suite's findings entirely. The cause is that a param
is **not** a field definition — `ActionParamSchema.type` is optional,
because a field-backed param inherits its type at runtime, "not visible
at parse time" per that schema's own refinement comment. So
`referenceTargetOf` answered `undefined` for **every param that declares
no type**, and the corpus param `{ name: 'owner', reference: 'user' }`
(`user` being the classic miss for `sys_user`) stopped being checked.
The swap deleted a live check.

Nothing was owed there in the other direction either: a carrier-less
`user` param already produced no finding, because `check` returns early
on absence. Reverted, verdict recorded as not-a-defect, with a
regression guard kept beside it.

## The four repairs

1. **`metadata-protocol` seed-loader — SILENT, and it stored a wrong
value.** A `{type:'user'}` field with no `reference` contributed no
`dependsOn` edge and never reached `references`, so its natural key was
written **verbatim** into a column holding a record id.
2. **`rest` public-form picker — LOUD.** A `publicPicker` on a
spec-complete `{type:'user'}` field answered `500
LOOKUP_TARGET_MISSING`. Now `200` over `sys_user`.
3. **`lint` `object-graph.graphFieldOf` — SILENT and widest.** The slice
feeds `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits
`user`; a carrier-less one answered `hop-untargeted`, which
`isUnjudgeable` treats as "the graph could not answer" and
`describeFieldPathVerdict` renders as *nothing*. Every rule in the
package that resolves a field path therefore **stopped judging** any
path through such a field — the failure mode `isUnjudgeable`'s own
docblock says this family exists to end ("a missed report is silence").
4. **`lint` `validate-field-consumers.walkObject` — SILENT.** The
`displayField` consumer edge onto `sys_user` was never recorded, so a
field that column displays was reported consumed by nobody. ⚠️
Materiality stated honestly: recordable only where `sys_user` is
compiled into the linted stack.

**Nothing widens.** `user` is the only member of
`IMPLICIT_REFERENCE_TARGETS`; `lookup` / `master_detail` / `tree` with
an absent carrier still name nothing, pinned at every repaired site.
**The unreadable-carrier refusal is unchanged** — `referenceTargetOf`
reads the carrier through `referenceCarrierOf` *before* it judges the
type, so objectstack-ai#13053/objectstack-ai#18550's `TypeError` still fires everywhere it fired.

⛔ **Not a re-widening of objectstack-ai#12920.** A control pins it: a `user` field
spelling `referenceTo: 'zzz_aliased_object'` resolves `sys_user` from
the type and is never asked for the aliased name; a `lookup` spelling
the same alias still resolves nothing and still answers `500`.

## Evidence

- `@objectstack/lint` — **106 files / 4019 tests passed**
- `@objectstack/metadata-protocol` — **184 passed, 3 skipped / 2627
passed, 19 skipped**
- `@objectstack/rest` — **194 files / 3254 passed, 1 skipped**
- `typecheck` green on all three (incl. `check:test-typecheck` for lint
and rest)
- Re-run in full **after** merging `origin/main`; the closure was
rebuilt first because `packages/spec` moved on main's side
- **Gates:** `dispatch-gates --ran` reconciles **63 derived / 60 run / 3
NOT MEASURED / 0 UNRUN**. The three are `PREREQUISITE NOT MET` (exit 3,
⛔ not a pass): `check:dual-build-cjs-loads` and `check:type-check-debt`
need a whole-repo build; `check-plugin-teardown-shape --self-test`
cannot reach a commit-pinned fixture on a shallow clone.
- **`pnpm lint`, narrowed and declared:** ① the population is read from
`eslint.config.mjs`, which states in its own comment that this repo
"never enables type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file" ⇒ this diff cannot move the
verdict on any file it does not touch; ② `--format json` reports **37
files linted, 0 errors, 0 warnings** (a superset — the three-dot set
includes what the merge brought); ③ measured at `97b689b`.
- Control-character self-scan over the 12 changed files: 0 hits.

## Attribution

Authored by Claude Code session `session_01UDXER3sdqfeVYpEWZs5mZx`.
Recorded here in prose deliberately: this body was edited on the raw
REST edit side, which appends the BARE footer form carrying no session
id (pm-dispatch `references/platform-readings.md`:350).

## Acceptance notes

- **Noted, not filed:** `referenceTargetOf` takes no `reader` label,
while `referenceCarrierOf` does precisely so "the message says who could
not read it". Every site moved onto the target arbiter therefore loses
its own name from the refusal, and four existing pins were retargeted
from the site label to `/referenceTargetOf/` here. The error class, the
offending shape and the prescription stay asserted, and this matches the
two landed repairs (objectstack-ai#19198, objectstack-ai#19264). It is a diagnostic-fidelity
question about the arbiter's signature, ⛔ not a defect — fixing it would
edit `packages/spec/src/data/field-value.zod.ts` and change this card's
landing path. **Successor: the next PR that moves a consumer onto
`referenceTargetOf`.**
- **Noted, not filed:** `graphFieldOf` no longer populates `reference`
for a **non-relationship** field carrying a stray `reference` (e.g.
`{type:'text', reference:'foo'}`), because `referenceTargetOf` returns
`undefined` outside `REFERENCE_VALUE_TYPES`. ⚠️ **Corrected by the
at-tier contract review (`5754774179`), re-measured on this head by the
seat:** `resolveFieldPath`'s `RELATIONSHIP_FIELD_TYPES` gate is on the
intermediate HOPS (`object-graph.ts:405`, `hop-untargeted`), ⛔ not on
the leaf — the leaf comes back `ok` carrying its `meta` whatever its
type (`object-graph.ts:413`) — and `validate-preset-comparands.ts:450`
DOES read it (`verdict?.kind === 'ok' ? strName(verdict.meta?.reference)
: undefined`). So «no other module reads `GraphField.reference`» is
**false on the tree**, and the sentence is withdrawn. What actually
follows: a `user` picker's filter rows now bind to `sys_user`, which is
the object the route queries — correct; a `text`-plus-stray-carrier
picker's filter rows bind to nothing where they bound to the stray
object. The same shape reaches the REST picker, which has no field-type
gate before `referenceTargetOf` (`rest-server.ts:10935`) and whose
`FieldSchema.reference` carries no non-reference-type refusal, so
`{type:'text', reference:'foo'}` behind a `publicPicker` now answers 500
`LOOKUP_TARGET_MISSING` where it used to search `foo`. Both are
pull-backs to the declared contract — the `reference` describe text
scopes it to lookup/master_detail fields, and `forms.mdx:238` scopes
pickers to lookup / master-detail / `user` — on a shape outside the
documented surface, so ⛔ neither is a widening and `Clause-②: no` is
unaffected. Strictly more correct: a `text` field's stray carrier is not
a target.

## 维护者速读(草稿)

**改了什么** — `user` 字段的目标由类型常量决定(`sys_user`),不必作者手写 `reference`。本 PR 普查了全部
17 个读「目标」的调用点,逐个判定该问哪个仲裁器,修了其中 4 个真缺陷,其余 13 个判为「读载体本来就对」并留档。

**为什么改** — 契约白纸黑字写着这种元数据「已完整声明」,但四处消费者把它当作「没写目标」。后果:公开表单上点开「负责人」选择器直接
500 错误页;种子数据把人名原样写进本该存记录 id 的列;lint 静默放弃对这类字段路径的全部校验。

**风险与代价(含回滚)** — 风险低。没有放宽任何拼写:别名仍旧拒收,不可读的载体仍旧抛错,`lookup`/`master_detail`
缺目标仍旧当作没目标。回滚即 revert 本 PR,无数据迁移、无存量数据改写。

**席位意见** — *(留空,待席位定稿)*

**你要做的** — 确认一件事即可:**本 PR 没有改动 `packages/spec`
的两个仲裁器**,所以不触发合流闸的路径腿。其余按常规复核。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

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/l tests tooling

Projects

None yet

2 participants