Skip to content

Commit 2b321a4

Browse files
os-warrenclaude
andauthored
fix(spec-consumers): the implicit-target census — 17 sites judged, four repaired through referenceTargetOf (#19289) (#19472)
Fixes #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. #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` (#19293) | 2026-09-20T10:37Z | | `plugin-audit/src/audit-writers.ts:429` — raw read → target, so the target count rose | `5636641` (#19264 / PR #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 **#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 #13053/#18550's `TypeError` still fires everywhere it fired. ⛔ **Not a re-widening of #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 (#19198, #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>
1 parent d00692f commit 2b321a4

13 files changed

Lines changed: 607 additions & 35 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289).
8+
9+
`IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's 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**." 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. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired.
10+
11+
Clause-②: no
12+
13+
Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it.
14+
15+
- **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions.
16+
- **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key.
17+
- **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody.
18+
- **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site.
19+
- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it.
20+
- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it.

‎content/docs/ui/forms.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ sections: [{
257257
| `displayFields` | Fields projected into each result row (plus `id`); the visitor's `q` is `contains`-matched against the **first** entry. At most 5; omitted → `['name']`. |
258258
| `maxResults` | Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is **no pagination** on this surface (`offset` is pinned to 0), so a leaked endpoint cannot enumerate the table. |
259259
| `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. |
260-
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, and only that key. A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved — the route answers `500 LOOKUP_TARGET_MISSING` — because `FieldSchema` accepts no spelling but `reference`. That key is also **read through the one carrier accessor**, so a stored row whose `reference` holds something other than a string (an object, an array) is refused rather than searched — see the error table below. Declare it only to search something other than what the field points at. |
260+
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, or — for a field type whose target is fixed by the TYPE rather than chosen by the author (`user`, whose constant is `sys_user`) — that constant. Those two are the only sources, and a `user` field authored without `reference` is fully specified, not under-specified (#19289). A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved, because `FieldSchema` accepts no spelling but `reference`: on a `lookup` / `master_detail` the route then answers `500 LOOKUP_TARGET_MISSING`, and on a `user` field the type's own constant answers instead — the alias contributes nothing either way. That key is also **read through the one carrier accessor**, so a stored row whose `reference` holds something other than a string (an object, an array) is refused rather than searched — see the error table below. Declare it only to search something other than what the field points at. |
261261

262262
Those four keys are the whole block. It admits exactly what the route enforces
263263
— an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,95 @@ describe('object-graph — a non-record entry in `stack.objects` (#15494)', () =
291291
expect(resolveFieldPath(g, 'b', 'n')).toMatchObject({ kind: 'ok' });
292292
});
293293
});
294+
295+
/**
296+
* [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the
297+
* third defect found by the implicit-target census, and the widest-reaching of
298+
* the four.
299+
*
300+
* `RELATIONSHIP_FIELD_TYPES` admits `user`, so `resolveFieldPath` hops through
301+
* one. The slice's `reference` came from `referenceCarrierOf`, which answers
302+
* what the CARRIER says — and `IMPLICIT_REFERENCE_TARGETS`
303+
* (`packages/spec/src/data/field-value.zod.ts`) declares a `user` field's
304+
* target "a CONSTANT OF THE TYPE", with metadata authored without `reference`
305+
* "fully specified, not under-specified". So a spec-complete field answered
306+
* `hop-untargeted`.
307+
*
308+
* ## Why that is the SILENT class, not a false positive
309+
*
310+
* `hop-untargeted` is `isUnjudgeable`, and `describeFieldPathVerdict` returns
311+
* `undefined` for it — "the graph could not answer". Every rule in this package
312+
* that resolves a field PATH therefore STOPPED JUDGING any path through an
313+
* author's "responsible person" column, reporting nothing at all. That is the
314+
* failure mode `isUnjudgeable`'s own docblock says this family exists to end:
315+
* "a missed report is silence".
316+
*
317+
* The repair is not an arbiter swap at the call — `graphFieldOf` synthesized
318+
* `{ reference: def.reference }` and threw `type` away before the arbiter could
319+
* see it. The field is now passed through whole.
320+
*/
321+
describe('[#19289] object-graph — a `user` field takes its target from the TYPE', () => {
322+
const implicitStack = {
323+
objects: [
324+
{
325+
name: 'crm_task',
326+
fields: {
327+
subject: { type: 'text', label: 'Subject' },
328+
// Spec-complete: no `reference`, because the type supplies it.
329+
assignee: { type: 'user', label: 'Assignee' },
330+
// The same field, with the constant materialized by hand.
331+
assignee_explicit: { type: 'user', label: 'Assignee', reference: 'sys_user' },
332+
// ⛔ The boundary: `lookup` has no constant, so it stays untargeted.
333+
orphan: { type: 'lookup', label: 'Orphan' },
334+
},
335+
},
336+
{ name: 'sys_user', fields: { name: { type: 'text' }, email: { type: 'email' } } },
337+
],
338+
};
339+
const implicitGraph = indexObjectGraph(implicitStack);
340+
341+
it('THE DEFECT: `assignee.email` RESOLVES — it is no longer `hop-untargeted`', () => {
342+
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'assignee.email');
343+
expect(verdict).toMatchObject({ kind: 'ok', object: 'sys_user', field: 'email' });
344+
// The load-bearing half: an unjudgeable verdict is what made every
345+
// consuming rule fall silent, so this is what actually ended.
346+
expect(isUnjudgeable(verdict)).toBe(false);
347+
});
348+
349+
it('the two legal spellings of one fully-specified field resolve identically', () => {
350+
expect(resolveFieldPath(implicitGraph, 'crm_task', 'assignee.email'))
351+
.toEqual(resolveFieldPath(implicitGraph, 'crm_task', 'assignee_explicit.email'));
352+
});
353+
354+
it('a MISS through the implicit hop is now REPORTED, where it used to be swallowed', () => {
355+
// The other direction, and the one that proves judgement resumed rather
356+
// than merely changing shape: a typo'd leaf beyond the hop produces a real
357+
// finding instead of silence.
358+
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'assignee.emial');
359+
expect(verdict).toMatchObject({ kind: 'field-unknown', object: 'sys_user', field: 'emial' });
360+
expect(isUnjudgeable(verdict)).toBe(false);
361+
});
362+
363+
it('control: `lookup` with no carrier is STILL `hop-untargeted` — only `user` has a constant', () => {
364+
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'orphan.x');
365+
expect(verdict).toMatchObject({ kind: 'hop-untargeted' });
366+
expect(isUnjudgeable(verdict)).toBe(true);
367+
});
368+
369+
it('control: an EXPLICIT carrier still wins over the constant', () => {
370+
// `reference` MATERIALIZES the constant for `user`; where an author named a
371+
// different object the arbiter returns what they wrote, unchanged. The
372+
// module fixture above relies on this (`owner` → `crm_person`).
373+
expect(resolveFieldPath(graph, 'crm_opportunity', 'account.owner.email'))
374+
.toMatchObject({ kind: 'ok', object: 'crm_person', field: 'email' });
375+
});
376+
377+
it('control: an UNREADABLE carrier still REFUSES at index time', () => {
378+
// `referenceTargetOf` reads the carrier through `referenceCarrierOf` before
379+
// it judges the type, so #13053's refusal is untouched — the implicit
380+
// target is not a fallback that swallows a broken carrier.
381+
const broken = { objects: [{ name: 'crm_task', fields: { assignee: { type: 'user', reference: { object: 'sys_user' } } } }] };
382+
expect(() => indexObjectGraph(broken)).toThrow(TypeError);
383+
expect(() => indexObjectGraph(broken)).toThrow(/`reference` is an object/);
384+
});
385+
});

‎packages/lint/src/object-graph.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
* second question about it is still unanswered — truthfully, and only there.
6969
*/
7070

71-
import { referenceCarrierOf } from '@objectstack/spec/data';
71+
import { referenceTargetOf } from '@objectstack/spec/data';
7272

7373
import { injectedColumnDefsFor, injectedColumnsFor } from './system-fields.js';
7474

@@ -239,7 +239,26 @@ function graphFieldOf(def: AnyRec): GraphField {
239239
// ⛔ NOT `strName` here. A carrier in a shape no reader can read is refused
240240
// rather than narrowed to `undefined` (#13053): every rule downstream reads
241241
// this slice, so a silent narrowing here is that blindness wholesaled.
242-
reference: referenceCarrierOf({ reference: def.reference }, 'object-graph graphFieldOf'),
242+
// `referenceTargetOf` reads the carrier through `referenceCarrierOf` before
243+
// it judges anything, so that refusal is unchanged.
244+
//
245+
// [#19289] The whole DEFINITION is passed through, and the arbiter is
246+
// `referenceTargetOf` — the question this slice answers is "what does this
247+
// field point at", ⛔ not "what does its carrier say", and for `user` the
248+
// two differ. `RELATIONSHIP_FIELD_TYPES` above admits `user`, so
249+
// {@link resolveFieldPath} traverses one — and a spec-complete
250+
// `{ type: 'user' }` field (`IMPLICIT_REFERENCE_TARGETS` declares its target
251+
// a CONSTANT OF THE TYPE, such metadata "fully specified, not
252+
// under-specified") read as `hop-untargeted`, which
253+
// {@link isUnjudgeable} treats as "the graph could not answer". Every rule
254+
// that resolves a path through an author's "responsible person" column
255+
// therefore STOPPED JUDGING IT, silently, across this package — the failure
256+
// mode the verdict union's own docblock says this family exists to end.
257+
//
258+
// Reading `def` whole rather than `{ reference: def.reference }` is what
259+
// makes the target question askable at all: the synthesized literal threw
260+
// `type` away before the arbiter could see it.
261+
reference: referenceTargetOf(def),
243262
multiple: def.multiple === true ? true : undefined,
244263
};
245264
}

‎packages/lint/src/validate-field-consumers.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,9 @@ describe('validateFieldConsumers — an unreadable `reference` carrier is refuse
513513
it('an OBJECT-valued carrier REFUSES — ⛔ not a silent missing edge', () => {
514514
const run = () => validateFieldConsumers(stackWith({ reference: { object: 'crm_account' } }));
515515
expect(run).toThrow(TypeError);
516-
expect(run).toThrow(/validate-field-consumers walkObject/);
516+
// [#19289] `walkObject` asks `referenceTargetOf`, which reads the carrier
517+
// through `referenceCarrierOf` and so names itself in the refusal.
518+
expect(run).toThrow(/referenceTargetOf/);
517519
expect(run).toThrow(/`reference` is an object/);
518520
expect(run).toThrow(/FieldSchema declares it as an optional STRING/);
519521
});
@@ -530,3 +532,60 @@ describe('validateFieldConsumers — an unreadable `reference` carrier is refuse
530532
expect(findings.map((f) => f.path)).toContain('objects[0].fields.legal_name');
531533
});
532534
});
535+
536+
/**
537+
* [#19289] The `displayField` consumer edge of a `{ type: 'user' }` field lands
538+
* on `sys_user` even when no `reference` is written — the fourth defect of the
539+
* implicit-target census.
540+
*
541+
* This walk has NO type gate, so a `user` field reaches it, and the read went
542+
* through `referenceCarrierOf` — what the CARRIER says.
543+
* `IMPLICIT_REFERENCE_TARGETS` (`packages/spec/src/data/field-value.zod.ts`)
544+
* declares a `user` field's target "a CONSTANT OF THE TYPE", with metadata
545+
* authored without `reference` "fully specified, not under-specified", so the
546+
* carrier answered `undefined` and the edge onto `sys_user.<displayField>` was
547+
* never recorded. The field that column DOES display was then reported
548+
* consumed by nobody — the same silent under-record #19198 and #19264 repaired
549+
* at their own consumers.
550+
*
551+
* ⛔ Materiality, stated so the pin is not read wider than it is: the edge is
552+
* only recordable where `sys_user` is compiled INTO the linted stack, which is
553+
* what this fixture arranges. Where it is not, the ledger never declared the
554+
* target and the outcome is unchanged.
555+
*
556+
* The repair is not an arbiter swap at the call — the synthesized
557+
* `{ reference: field.reference }` literal threw `type` away before the arbiter
558+
* could see it. The field is now passed through whole.
559+
*/
560+
describe('[#19289] validateFieldConsumers — a `user` field displays a field on `sys_user`', () => {
561+
const stackWithUser = (assignee: AnyRec): AnyRec => ({
562+
objects: [
563+
{ name: 'sys_user', fields: { name: { type: 'text' }, full_name: { type: 'text' } } },
564+
{
565+
name: 'crm_task',
566+
fields: {
567+
name: { type: 'text' },
568+
assignee: { type: 'user', displayField: 'full_name', ...assignee },
569+
},
570+
},
571+
],
572+
views: [{ name: 'task_list', object: 'crm_task', viewKind: 'list', columns: ['name'] }],
573+
});
574+
575+
/** Paths this rule reports — the displayed field appearing here IS the defect. */
576+
const pathsFor = (assignee: AnyRec) => validateFieldConsumers(stackWithUser(assignee)).map((f) => f.path);
577+
578+
it('THE DEFECT: with no `reference`, `sys_user.full_name` is no longer reported as consumed by nobody', () => {
579+
expect(pathsFor({})).not.toContain('objects[0].fields.full_name');
580+
});
581+
582+
it('the two legal spellings of one fully-specified field record the same edge', () => {
583+
expect(pathsFor({})).toEqual(pathsFor({ reference: 'sys_user' }));
584+
});
585+
586+
it('control: an UNREADABLE carrier still REFUSES — the implicit target does not swallow it', () => {
587+
const run = () => validateFieldConsumers(stackWithUser({ reference: { object: 'sys_user' } }));
588+
expect(run).toThrow(TypeError);
589+
expect(run).toThrow(/`reference` is an object/);
590+
});
591+
});

‎packages/lint/src/validate-field-consumers.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121

122122
import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data';
123123
import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
124-
import { referenceCarrierOf } from '@objectstack/spec/data';
124+
import { referenceTargetOf } from '@objectstack/spec/data';
125125
import { collectionEntries } from './collection-entries.js';
126126
import { recordsOf } from './object-graph.js';
127127
import { injectedColumnsFor } from './system-fields.js';
@@ -550,14 +550,26 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
550550
walk(ledger, value, objectName, 'objects', `${objPath}.${key}`, [key], key);
551551
}
552552
for (const { rec: field, path: fieldPath } of collectionEntries(obj.fields, fieldsPath)) {
553-
// [#18550] The carrier through the ONE arbiter: `strName` answered
553+
// [#18550] The target through the ONE arbiter: `strName` answered
554554
// `undefined` for an unreadable one exactly as it does for an absent one,
555555
// so the `displayField` consumer edge below was never recorded and the
556556
// ledger under-reported — a field a lookup DOES display read as unused.
557-
// Absence still answers `undefined` and records nothing.
558-
// Same form as the sibling lint readers: the literal `.reference` read
559-
// stays at the site, only the shape judgment moves to the arbiter.
560-
const reference = referenceCarrierOf({ reference: field.reference }, 'validate-field-consumers walkObject');
557+
// Absence still answers `undefined` and records nothing, and an unreadable
558+
// carrier still REFUSES (`referenceTargetOf` reads it through
559+
// `referenceCarrierOf` before it judges anything).
560+
//
561+
// [#19289] The whole FIELD is passed through and the arbiter is
562+
// `referenceTargetOf`, ⛔ not `referenceCarrierOf`. There is no type gate
563+
// here, so a `{ type: 'user', displayField: … }` field reaches this line —
564+
// and for `user` the carrier is not the target
565+
// (`IMPLICIT_REFERENCE_TARGETS`: a CONSTANT OF THE TYPE, such metadata
566+
// "fully specified, not under-specified"). Reading the carrier dropped the
567+
// edge to `sys_user.<displayField>` wherever `sys_user` is compiled into
568+
// the linted stack, so a field that column DOES display was reported unused
569+
// — the same silent under-record #19198 and #19264 repaired elsewhere.
570+
// The synthesized `{ reference: field.reference }` literal is what made the
571+
// target question unaskable: it threw `type` away before the arbiter saw it.
572+
const reference = referenceTargetOf(field);
561573
const displayField = strName(field.displayField);
562574
if (reference && displayField && ledger.declares(reference, displayField)) {
563575
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });

0 commit comments

Comments
 (0)