Skip to content

Commit a675ad4

Browse files
os-elon-muskclaude
andauthored
Refuse an unreadable reference carrier at the ten residual readers (ruling E item 2 residue) (#19080)
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@0ec8185`: | card's cited line (at `2f11e2db7`) | current line | text | moved? | |---|--:|---|---| | `objectql/src/engine.ts:13052` | `13096` | `const ref = fdef.reference;` | +44 | | `objectql/src/engine.ts:13491` | `13544` | `const ref = fdef.reference;` | +53 | | `rest/src/rest-server.ts:10835` | `10889` | `referenceObject = def?.reference;` | +54 | | `metadata-protocol/src/seed-loader.ts:701` | `701` | `fieldDef.reference` | — | | `lint/src/validate-expressions.ts:380` | `380` | `const ref = def.reference;` | — | | `lint/src/validate-field-consumers.ts:552` | `552` | `const reference = strName(field.reference);` | — | | `lint/src/validate-object-references.ts:297` | `297` | `strName(field.reference),` | — | | `lint/src/validate-object-references.ts:316` | `316` | `strName(param.reference),` | — | | `lint/src/validate-sharing-rule-enforceability.ts:261` | `261` | `const ref = f.reference;` | — | | `verify/src/derive.ts:136` | `136` | `const ref = f?.reference;` | — | **That is 10 cited LINES in 8 FILES.** Neither is 9. The 9 is reachable only by mixing granularities, and triage's own parenthesis shows the mix: 「objectql ×2、rest、metadata-protocol、lint ×4、verify」 = 2 objectql LINES + 3 non-lint FILES + 4 lint FILES = 9, counting objectql per line and lint per file. PR #18503's own "10 sites" is its 11-line C3 list (the 10 above plus `validate-preset-comparands.ts:431`) with one pair merged. Both numbers are arithmetic over the same list; **the list is the fact, the count was the rotting reading.** - ⭐ `validate-preset-comparands.ts:431` (now `:450`) is confirmed **not residue**, on the same reading the contract review gave: it reads `verdict.meta?.reference`, and `meta` is the `GraphField` slice `graphFieldOf` builds — which PR #18503 routed. It is covered transitively, and so are `object-graph.ts:383` and `:386`. - ⛔ Not routed, and flagged rather than widened: `objectql/src/engine.ts:9033` (`cd?.reference === parent.name` in `buildSummaryIndex`) produces the same silent skip — "can't resolve the relationship — skip", so a `summary` field never recomputes. It is **already classified**, as `engine.ts:8989` in PR #18503's **C2** list, a boundary that PR drew deliberately. Routing it would widen this card past its scope; it goes to the seat as a finding instead. ### The "four routed lint read sites", re-derived The card paraphrases PR #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: ```text BEFORE PROBE before-delete rows: acct=1 task=1 PROBE delete outcome: RESOLVED true PROBE after-delete rows: acct=0 task=1 <- an ORPHANED master_detail row AFTER PROBE before-delete rows: acct=1 task=1 PROBE delete outcome: THREW TypeError: ObjectQL.planCascadeAtomicity: `reference` is an object, and FieldSchema declares it as an optional STRING … PROBE after-delete rows: acct=1 task=1 <- nothing touched ``` No `restrict` refusal, no `set_null`, nothing logged, and the caller told the delete succeeded. The refusal now fires before any row is touched, because `delete()` calls `planCascadeAtomicity` first. ⭐ Two facts the probe forced, both worth recording: the engine's own WRITE path already refuses this shape (`insert` runs `assertReferencesResolve` → `referenceTargetOf` → the same arbiter), so the child row had to be written straight through the driver — which is exactly the provenance the arbiter's docblock names (a raw `registerObject`, a stored row rehydrated past its schema). And the registry's own completeness check printed `field/relationship-without-reference` while the cascade stayed silent: the platform reported the shape in one channel and mis-read it in another. ### Ablation — the pins can fail, proved on disk by content hash One routed read reverted through `scripts/ablation-replace.mjs` (⛔ not `sed -i`), on the committed tree: ```text anchor "const ref = referenceCarrierOf(fdef, 'ObjectQL.planCascadeAtomicity');" x1 -> x0 replace "const ref = fdef.reference;" x0 -> x1 blob 4ca2839 -> 6121f5c9bf429db31f959af2e43711506db8baf0 ok mutation landed: anchor 1 -> 0, blob 4ca2839 -> 6121f5c9bf42 x seam 1 (planCascadeAtomicity): an unreadable carrier refuses the delete BEFORE any row is touched Test Files 1 failed (1) Tests 1 failed | 5 passed (6) restore blob after restore 4ca2839 blob at HEAD 4ca2839 git diff HEAD empty ``` Restored with the explicit form, verified independently afterwards: `git diff HEAD` empty and `git hash-object` equal to the HEAD blob. The redness is targeted — exactly the mutated seam's pin failed while seam 2 and all three absence controls stayed green, so the pins are per-site rather than one shared assertion. ### Tests | package | result | |---|---| | `@objectstack/objectql` | 300 files / **5009 passed** | | `@objectstack/rest` | 194 files / **3245 passed**, 1 skipped | | `@objectstack/metadata-protocol` | 180 files / **2585 passed**, 19 skipped | | `@objectstack/lint` | 104 files / **3935 passed** | | `@objectstack/verify` | 15 files / **116 passed** | | `typecheck` (all five, incl. test layers) | exit 0; `objectql`'s shrink-only test ledger held at 40 files / 234 errors / 65 pinned signatures — not raised | ⭐ `check:type-check-debt` re-measured all 4 DEBT ledger entries at the final head: 53 raw errors, **none above its recorded number**. ### Repo-wide lint — run, ⛔ not narrowed `pnpm lint` (`eslint . --no-inline-config`) at `bd1b3dcad`: **exit 0**, 75s. No narrowing is claimed, so no narrowing evidence is owed. ### Gate census — derived from the REAL change set > ⚠️ **Seat correction, added after the body's single dev write.** The census in this section, and the widening-tells reading below it, were taken at head `bd1b3dcad` (17 paths) and are described here as 「at the final head」 — they are not. The doc commit made the head `a17094d94` (18 paths), and `dispatch-gates.mjs --commands` derives **93** families there, the extra **28** being the doc family (`check-doc-frontmatter`, `check:doc-anchors`, `check:docs-single-h1`, `check:docs-redirects`, `spec check:docs`, `check:skill-examples`, …). The dev re-derived and ran them — 93 derived / 92 run / 1 NOT MEASURED, and the at-tier reviewer independently ran all 28 (27 exit 0, 1 prerequisite refusal, bound green by CI's `Lint & Repo Gates`) — so ⛔ no outcome is at stake; the numbers written in this section are simply not the ones for this head. Likewise 「17 changed paths」 and 「17 NOT MEASURED」 read 18 here. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head, every command run with `$?` captured **before any pipe**, then reconciled with `--ran`: ```text Run reconciliation — 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. NOT-MEASURED · DERIVED (1) — your record carries exit 3 for these, the number a gate refusing its own prerequisite exits with: - pnpm check:dual-build-cjs-loads [recorded exit 3 on line 41 — PREREQUISITE NOT MET] ``` - **64 families exit 0.** - **`pnpm check:dual-build-cjs-loads` — `PREREQUISITE NOT MET` (exit 3), ⛔ neither a pass nor a finding.** Its own words: *"this gate reads built output, and some package has no dist/ … Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured."* 35 packages outside this card's build closure have no `dist/`; the prerequisite is a repo-wide build, which is what CI does before this step. Bounding fact, measurable from the diff: **zero** `package.json`, `tsup`/`tsconfig`, `turbo.json` or workflow files are touched — 17 changed paths, all `.changeset/` plus `packages/**/src`. - Two gates were **red or unmeasured first and fixed at the source, ⛔ never at the ledger**: - `check:objectql-double-limit` graded the new test file's `find` double limit-blind. Fixed in the double (the caller's bound applied after the filter, by presence). ⛔ The baseline was not touched; the re-run prints `baseline key set verified against 0ec8185: no files added`. - `check:type-check-debt` exited 3 twice on its own prerequisite — first for an unbuilt `@objectstack/driver-turso`, then because the ablation's restore rewrote `engine.ts` and left `dist/` older than source by mtime. Both satisfied by building, then exit 0. ⛔ No ledger entry was raised on an exit 3, which that gate forbids explicitly. ### Clause ② — the widening-tells reading, with its NOT MEASURED rows `node scripts/pm/check-widening-tells.mjs --declaration yes --diff …` → **exit 0**: ```text ✓ check-widening-tells: the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ``` ⚠️ That is a **short-circuit, not a reading**: with `yes` the gate examines no file. So the same diff was also run with `--declaration no` as a DIAGNOSTIC (⛔ not a declaration — the declaration is `yes`, and it is the seat's), to get the per-file reading: ```text ✓ check-widening-tells: 17 changed file(s) — 0 judged against a declared surface (no widening tell), 17 NOT MEASURED. ⛔ NOTHING on this diff was examined for widening tells, so this exit 0 is evidence about no surface at all. ⛔ NOT MEASURED is not a clean reading: no declared surface covers it (17): … all 17 changed paths … ``` ⇒ **17 NOT MEASURED rows, zero judged. ⛔ Not a clean reading**, and reported as such: the gate's four tells cover Zod schema surfaces, closed sets, published export listings and registries, and this diff touches none of them. `--self-test` passes (510 cases). The declaration stands at `yes` on the ground the seat's claim records — the observable behaviour at published runtime doors (`rest-server.ts`, `objectql` cascade delete) changes — ⛔ and this PR does not review its own verdict. ### Changeset One changeset, **`minor` × 5**, with the level's reason stated rather than assumed. `minor` and not `patch` because the declaration is `Clause-②: yes` and `check-changeset-no-major`'s level axis requires at least one moved published package graded `minor` or above; `minor` and not a breaking grade because nothing conformant changes — a non-string `reference` could not be authored, stored or parsed before this release either. That is the same grading and the same "Upgrading: nothing conformant changes" reasoning PR #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 #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](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c70581b commit a675ad4

18 files changed

Lines changed: 1011 additions & 21 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/metadata-protocol": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/verify": minor
7+
---
8+
9+
The remaining raw `FieldSchema.reference` readers now **REFUSE** a carrier they cannot read, instead of answering "no target" (#18550). The previous release routed the arbiter (`referenceCarrierOf`) and the lint target readers; these were the measured residue of the same ruling — every reader, not just the arbiter.
10+
11+
`FieldSchema.reference` is `z.string().optional()`, so `ObjectSchema.safeParse` refuses an object- or array-valued carrier at the contract door. These reads are the other door: the one a value reaches only when it never went through parse — a hand-built fixture, a raw `registerObject`, a stored row rehydrated past its schema.
12+
13+
**`@objectstack/objectql`** — both of the delete cascade's carrier reads (`planCascadeAtomicity` and `cascadeDeleteRelations`). This is the one with a measurable runtime consequence, and it is why the level is not `patch`:
14+
15+
```
16+
before acct=1 task=1
17+
delete RESOLVED true <- success reported to the caller
18+
after acct=0 task=1 <- an ORPHANED master_detail row
19+
```
20+
21+
An unreadable carrier made the relation invisible to the cascade, so the parent was deleted, the detail row stayed, and the caller was told the delete succeeded — no `restrict` refusal, no `set_null`, nothing logged. It now refuses before any row is touched.
22+
23+
**`@objectstack/rest`** — the public-form lookup picker's field-def fallback. The field def is also hoisted out of the metadata fetch's `catch {}`, so an unreadable carrier is no longer reported as `LOOKUP_TARGET_MISSING`: "no target is declared" and "the declared target cannot be read" want different fixes from whoever owns the metadata.
24+
25+
**`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked.
26+
27+
**`@objectstack/lint`** — the four remaining target readers: `masterDetailCount` (`validate-expressions`), the `displayField` consumer edge (`validate-field-consumers`), the field and action-param targets (`validate-object-references`), and `masterOf` (`validate-sharing-rule-enforceability`).
28+
29+
**`@objectstack/verify`** — `relationTarget`, which no longer degrades an unreadable carrier to the generic "has no `reference` target" an object with no relationship metadata at all receives.
30+
31+
`null`, `undefined` and `''` are ABSENCE, not a wrong shape, and still answer `undefined` at every one of these sites — a field is allowed to name no target, and `StrictField` declares `reference` nullable. Each site's absence answer is pinned alongside its refusal.
32+
33+
Upgrading: nothing conformant changes. A non-string `reference` could not be authored, stored or parsed before this release either; what changes is that one now fails loudly at the read instead of being read as an absent target. If a test asserted the old silence, assert the refusal instead.

‎content/docs/ui/forms.mdx‎

Lines changed: 2 additions & 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`. 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, 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. |
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
@@ -285,6 +285,7 @@ Errors:
285285
| `403 LOOKUP_NOT_PUBLIC` | the field has no `publicPicker` block — the deliberate loud default (#3022); also any server-managed anchor (`owner_id`, `organization_id`, …), which never gets a picker even if one is declared |
286286
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
287287
| `500 LOOKUP_TARGET_MISSING` | the referenced object could not be resolved from either `publicPicker.object` or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical `reference`; declaring `object` was the workaround and is no longer needed. |
288+
| `500 INTERNAL_ERROR` | the field def **declares** a target this route cannot READ — a stored `reference` holding an object or an array rather than the object name `FieldSchema` declares. ⚠️ Deliberately **not** `LOOKUP_TARGET_MISSING`: "nothing names the target" and "the named target is unreadable" want different fixes from whoever owns the metadata, so they get different answers. The unreadable carrier is named in full in the server log (it is withheld from the response body, as every fault's text is); the picker's search never runs. |
288289

289290
### Auth model
290291

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4234,3 +4234,62 @@ describe("validateStackExpressions — a non-record entry in an object's `fields
42344234
expect(issues[0].message).toContain('amount');
42354235
});
42364236
});
4237+
4238+
/**
4239+
* [#18550] `masterDetailCount` must refuse a `reference` carrier it cannot
4240+
* read, rather than counting the relationship as undeclared.
4241+
*
4242+
* One of the measured residue sites of ruling letter E item 2 on #18095. The
4243+
* count was `typeof ref === 'string' && ref.trim() !== ''`, which gives an
4244+
* UNREADABLE carrier the same answer as an ABSENT one — so an object whose
4245+
* master IS declared counted zero masters, and the `parent`-scope gate above
4246+
* reported "declares no `master_detail` relationships" about metadata that
4247+
* declares one. A finding about the wrong thing: the relationship is not
4248+
* missing, its target is unreadable, and the two want different fixes.
4249+
*
4250+
* Absence keeps its answer: `undefined`, `null`, `''` and a whitespace-only
4251+
* carrier all still count as no master, because none of them names an object.
4252+
*/
4253+
describe('masterDetailCount — an unreadable `reference` carrier is refused (#18550)', () => {
4254+
const lintObject = (obj: Record<string, unknown>) => () => validateStackExpressions({ objects: [obj] });
4255+
const detailOn = (carrier: Record<string, unknown>) => ({
4256+
name: 'inv_line',
4257+
fields: {
4258+
inv: { type: 'master_detail', ...carrier },
4259+
qty: { type: 'number', readonlyWhen: "parent.status == 'paid'" },
4260+
},
4261+
});
4262+
4263+
it('control: a READABLE carrier counts as a master, so the `parent` gate stays silent', () => {
4264+
// Without this, every refusal below could pass on a gate that had stopped
4265+
// resolving masters at all.
4266+
expect(lintObject(detailOn({ reference: 'inv' }))()).toHaveLength(0);
4267+
});
4268+
4269+
it('an OBJECT-valued carrier REFUSES — ⛔ not a "declares no master_detail" finding', () => {
4270+
const run = lintObject(detailOn({ reference: { object: 'inv' } }));
4271+
expect(run).toThrow(TypeError);
4272+
expect(run).toThrow(/validate-expressions masterDetailCount/);
4273+
expect(run).toThrow(/`reference` is an object/);
4274+
expect(run).toThrow(/FieldSchema declares it as an optional STRING/);
4275+
});
4276+
4277+
it('an ARRAY-valued carrier refuses too, naming the shape it found', () => {
4278+
expect(lintObject(detailOn({ reference: ['inv', 'inv2'] }))).toThrow(/`reference` is an array \(length 2\)/);
4279+
});
4280+
4281+
// ── ABSENCE: still counted as no master, still reported as the `parent`-gate
4282+
// finding, ⛔ never thrown on. These are the cases a mechanical
4283+
// throw-on-falsy sweep would break.
4284+
it.each([
4285+
['undefined (the key omitted)', {}],
4286+
['null (`StrictField` declares it nullable)', { reference: null }],
4287+
["'' (names no object)", { reference: '' }],
4288+
['whitespace only (names no object either)', { reference: ' ' }],
4289+
])('absence stays a FINDING, not a throw: %s', (_label, carrier) => {
4290+
const issues = lintObject(detailOn(carrier))();
4291+
const parentScope = issues.filter((i) => /reads `parent`/.test(i.message));
4292+
expect(parentScope).toHaveLength(1);
4293+
expect(parentScope[0]!.message).toMatch(/declares no `master_detail` relationships/);
4294+
});
4295+
});

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio
101101
// hand-written notion of "blank" here — that drift is what #15662 built the
102102
// shared refusal to prevent.
103103
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
104+
import { referenceCarrierOf } from '@objectstack/spec/data';
104105

105106
import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js';
106107
import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js';
@@ -377,8 +378,22 @@ function masterDetailCount(obj: AnyRec): number {
377378
// rejected alias — `field.zod.ts:331` maps it to `reference` in the strict
378379
// error map, so a field spelling it does not parse (#5017). See the
379380
// `## Scope` table on this module for why a consumer must not re-admit it.
380-
const ref = def.reference;
381-
if (typeof ref === 'string' && ref.trim() !== '') n += 1;
381+
//
382+
// [#18550] Read through the ONE arbiter. ABSENCE is unchanged and still
383+
// uncounted — `undefined` / `null` / `''` answer `undefined`, and the
384+
// `.trim()` test below still drops a whitespace-only carrier, which names
385+
// no object either. UNREADABILITY used to be uncounted too, and that is
386+
// the silence: an object-valued carrier made a declared `master_detail`
387+
// invisible to this count, so `parent` was judged unbound (or two masters
388+
// read as one) from metadata that does declare a relationship.
389+
// ⭐ The literal `.reference` read STAYS here, in the argument, and only
390+
// the SHAPE judgment moves out — the form `validate-security-posture.ts`
391+
// and `data-model-rules.ts` already use, and for their stated reason: the
392+
// #5017 receiver meta-test reads this rule's SOURCE to prove it reads
393+
// `reference` and never an alias, and a read folded inside a helper call
394+
// would disarm that scan silently.
395+
const ref = referenceCarrierOf({ reference: def.reference }, 'validate-expressions masterDetailCount');
396+
if (ref !== undefined && ref.trim() !== '') n += 1;
382397
}
383398
return n;
384399
}

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,62 @@ describe('validateFieldConsumers (#15922)', () => {
471471
});
472472
});
473473
});
474+
475+
/**
476+
* [#18550] The `displayField` consumer edge must refuse a `reference` carrier
477+
* it cannot read, rather than recording no edge at all.
478+
*
479+
* One of the measured residue sites of ruling letter E item 2 on #18095. The
480+
* read was `strName(field.reference)`, which answers `undefined` for an
481+
* unreadable carrier exactly as it does for an absent one — so a field a
482+
* lookup DOES display was recorded as consumed by nobody, and this rule then
483+
* reported it as carrier-only. The ledger under-reported, and the finding
484+
* pointed at the displayed field instead of the unreadable carrier.
485+
*
486+
* Absence keeps its answer: with no target there is no object to look a
487+
* `displayField` up on, so no edge is recorded and nothing throws.
488+
*/
489+
describe('validateFieldConsumers — an unreadable `reference` carrier is refused (#18550)', () => {
490+
const stackWith = (carrier: AnyRec): AnyRec => ({
491+
objects: [
492+
{ name: 'crm_account', fields: { name: { type: 'text' }, legal_name: { type: 'text' } } },
493+
{
494+
name: 'crm_contact',
495+
fields: {
496+
name: { type: 'text' },
497+
account: { type: 'lookup', displayField: 'legal_name', ...carrier },
498+
},
499+
},
500+
],
501+
// A consumer root OTHER than `objects` is this rule's entry condition
502+
// (`hasConsumerRoot`): with only `objects` present it returns early and
503+
// never walks a field, so a fixture without one would make every case
504+
// below vacuous.
505+
views: [{ name: 'contact_list', object: 'crm_contact', viewKind: 'list', columns: ['name'] }],
506+
});
507+
508+
it('control: a READABLE carrier records the `displayField` edge, so the target is not carrier-only', () => {
509+
const findings = validateFieldConsumers(stackWith({ reference: 'crm_account' }));
510+
expect(findings.map((f) => f.path)).not.toContain('objects[0].fields.legal_name');
511+
});
512+
513+
it('an OBJECT-valued carrier REFUSES — ⛔ not a silent missing edge', () => {
514+
const run = () => validateFieldConsumers(stackWith({ reference: { object: 'crm_account' } }));
515+
expect(run).toThrow(TypeError);
516+
expect(run).toThrow(/validate-field-consumers walkObject/);
517+
expect(run).toThrow(/`reference` is an object/);
518+
expect(run).toThrow(/FieldSchema declares it as an optional STRING/);
519+
});
520+
521+
it.each([
522+
['undefined (the key omitted)', {}],
523+
['null (`StrictField` declares it nullable)', { reference: null }],
524+
["'' (names no object)", { reference: '' }],
525+
])('absence records no edge and does NOT throw: %s', (_label, carrier) => {
526+
// With no target there is no object to resolve `displayField` against, so
527+
// the displayed field is genuinely unconsumed here — the rule's ordinary
528+
// answer, reached without a throw.
529+
const findings = validateFieldConsumers(stackWith(carrier as AnyRec));
530+
expect(findings.map((f) => f.path)).toContain('objects[0].fields.legal_name');
531+
});
532+
});

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +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';
124125
import { collectionEntries } from './collection-entries.js';
125126
import { recordsOf } from './object-graph.js';
126127
import { injectedColumnsFor } from './system-fields.js';
@@ -549,7 +550,14 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
549550
walk(ledger, value, objectName, 'objects', `${objPath}.${key}`, [key], key);
550551
}
551552
for (const { rec: field, path: fieldPath } of collectionEntries(obj.fields, fieldsPath)) {
552-
const reference = strName(field.reference);
553+
// [#18550] The carrier through the ONE arbiter: `strName` answered
554+
// `undefined` for an unreadable one exactly as it does for an absent one,
555+
// so the `displayField` consumer edge below was never recorded and the
556+
// 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');
553561
const displayField = strName(field.displayField);
554562
if (reference && displayField && ledger.declares(reference, displayField)) {
555563
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });

0 commit comments

Comments
 (0)