From 33280882dce4d6deabdba110a060e1272d8a6007 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 15:48:22 +0000 Subject: [PATCH 1/5] fix(spec-carrier): route the residual `reference` carrier reads through the one arbiter Ruling letter E item 2 asked for the loud refusal at EVERY reader of `FieldSchema.reference`. PR #18503 delivered it at the arbiter (`referenceCarrierOf`) and the lint read sites; these ten reads still answered "no target" for a carrier no reader can read. Each site keeps absence and unreadability as DIFFERENT answers: `null`, `undefined` and `''` still answer `undefined` and are still silent (the key is `.optional()` and `StrictField` declares it nullable); only a carrier in a shape the contract does not admit refuses. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- packages/lint/src/validate-expressions.ts | 13 ++++++-- packages/lint/src/validate-field-consumers.ts | 8 ++++- .../lint/src/validate-object-references.ts | 17 +++++++++-- .../validate-sharing-rule-enforceability.ts | 10 +++++-- packages/metadata-protocol/src/seed-loader.ts | 22 ++++++++++---- packages/objectql/src/engine.ts | 30 +++++++++++++++++-- packages/rest/src/rest-server.ts | 28 +++++++++++++++-- packages/verify/src/derive.ts | 13 ++++++-- 8 files changed, 121 insertions(+), 20 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 163d7e82fdd..1925a44a27e 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -101,6 +101,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio // hand-written notion of "blank" here — that drift is what #15662 built the // shared refusal to prevent. import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; +import { referenceCarrierOf } from '@objectstack/spec/data'; import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js'; import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; @@ -377,8 +378,16 @@ function masterDetailCount(obj: AnyRec): number { // rejected alias — `field.zod.ts:331` maps it to `reference` in the strict // error map, so a field spelling it does not parse (#5017). See the // `## Scope` table on this module for why a consumer must not re-admit it. - const ref = def.reference; - if (typeof ref === 'string' && ref.trim() !== '') n += 1; + // + // [#18550] Read through the ONE arbiter. ABSENCE is unchanged and still + // uncounted — `undefined` / `null` / `''` answer `undefined`, and the + // `.trim()` test below still drops a whitespace-only carrier, which names + // no object either. UNREADABILITY used to be uncounted too, and that is + // the silence: an object-valued carrier made a declared `master_detail` + // invisible to this count, so `parent` was judged unbound (or two masters + // read as one) from metadata that does declare a relationship. + const ref = referenceCarrierOf(def, 'validate-expressions masterDetailCount'); + if (ref !== undefined && ref.trim() !== '') n += 1; } return n; } diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 65aef65ae2c..50ed2767731 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -121,6 +121,7 @@ import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data'; import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; +import { referenceCarrierOf } from '@objectstack/spec/data'; import { collectionEntries } from './collection-entries.js'; import { recordsOf } from './object-graph.js'; import { injectedColumnsFor } from './system-fields.js'; @@ -549,7 +550,12 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj walk(ledger, value, objectName, 'objects', `${objPath}.${key}`, [key], key); } for (const { rec: field, path: fieldPath } of collectionEntries(obj.fields, fieldsPath)) { - const reference = strName(field.reference); + // [#18550] The carrier through the ONE arbiter: `strName` answered + // `undefined` for an unreadable one exactly as it does for an absent one, + // so the `displayField` consumer edge below was never recorded and the + // ledger under-reported — a field a lookup DOES display read as unused. + // Absence still answers `undefined` and records nothing. + const reference = referenceCarrierOf(field, 'validate-field-consumers walkObject'); const displayField = strName(field.displayField); if (reference && displayField && ledger.declares(reference, displayField)) { ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' }); diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index 9a0969e4204..7a73f40bd2d 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -79,6 +79,7 @@ import { isPlatformProvidedObjectName, PLATFORM_PROVIDED_OBJECT_NAMES, } from '@objectstack/spec/system'; +import { referenceCarrierOf } from '@objectstack/spec/data'; import { recordsOf, suggestName } from './object-graph.js'; @@ -293,8 +294,16 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { const type = strName(field.type); if (!type || !RELATIONSHIP_TARGET_FIELD_TYPES.has(type)) continue; const fieldName = strName(field.name) ?? `#${fi}`; + // [#18550] Through the ONE arbiter. `strName` gave an unreadable carrier + // the same answer as an absent one, and `check` returns early on + // `undefined` — so the field that most needs a target reported NOTHING: + // not `relationship/missing-reference` (the target is not missing), and + // not this rule's unknown-object error either. Absence still answers + // `undefined` and this rule still, deliberately, says nothing about it — + // an absent target is `field/relationship-without-reference`'s subject, + // not this rule's. check( - strName(field.reference), + referenceCarrierOf(field, 'validate-object-references field target'), `object "${objName}" · field "${fieldName}"`, `objects[${oi}].fields.${fieldName}.reference`, `${type} target`, @@ -312,8 +321,12 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { if (!param || typeof param !== 'object') continue; const paramLabel = strName(param.name) ?? strName(param.field) ?? `#${pi}`; const where = `${actionLabel} · param "${paramLabel}"`; + // [#18550] Same arbiter, same split. The carrier here is + // `ActionParamSchema.reference`, whose own docblock says the key name + // "deliberately mirrors `FieldSchema.reference` so the same spelling" + // carries the target object's name — one contract, so one reader. check( - strName(param.reference), + referenceCarrierOf(param, 'validate-object-references action param target'), where, `${actionPath}.params[${pi}].reference`, 'record-picker target', diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index 2ce88c0cb10..f2ef83cce21 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.ts @@ -137,6 +137,7 @@ */ import { compileCelToFilter } from '@objectstack/formula'; +import { referenceCarrierOf } from '@objectstack/spec/data'; import { recordsOf } from './object-graph.js'; /** A `condition` outside the pushdown subset — the rule is never seeded. */ @@ -258,8 +259,13 @@ function effectiveSharingModelOf(obj: AnyRec): 'private' | 'read' | 'public' { function masterOf(obj: AnyRec): string | undefined { for (const f of recordsOf(obj.fields)) { if (f.type === 'master_detail') { - const ref = f.reference; - if (typeof ref === 'string' && ref) return ref; + // [#18550] Through the ONE arbiter. An unreadable carrier used to read + // as "this master_detail names no master", so a `controlled_by_parent` + // detail whose master IS declared answered `undefined` here and the + // arm that needs the master went quiet. Absence still answers + // `undefined` and falls through to the `return undefined` below. + const ref = referenceCarrierOf(f, 'validate-sharing-rule-enforceability masterOf'); + if (ref) return ref; } } return undefined; diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index e383a91694b..ecedc1f0a4c 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -13,7 +13,7 @@ import type { SeedLoadResultParsed, Seed, } from '@objectstack/spec/data'; -import { SeedLoaderConfigSchema, isMultiValueField } from '@objectstack/spec/data'; +import { SeedLoaderConfigSchema, isMultiValueField, referenceCarrierOf } from '@objectstack/spec/data'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { resolveSeedRecord } from '@objectstack/formula'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; @@ -696,11 +696,21 @@ export class SeedLoaderService implements ISeedLoaderService { if (objDef && objDef.fields) { const fields = objDef.fields as Record; for (const [fieldName, fieldDef] of Object.entries(fields)) { - if ( - (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') && - fieldDef.reference - ) { - const targetObject = fieldDef.reference as string; + if (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') { + // [#18550] The carrier goes through the ONE arbiter, which also + // retires the `as string` cast this read used to carry — the cast + // asserted exactly what the truthiness test had not checked, so an + // object-valued carrier became a `targetObject` that matched no + // name in `objectSet`, contributed no `dependsOn` edge, and was + // then pushed onto `references` for resolution to make of what it + // could. ABSENCE is unchanged: `undefined` / `null` / `''` answer + // `undefined` and the field is skipped, which is what a relational + // field naming no target means. The type gate stays FIRST so the + // set of fields whose carrier is read here is byte-identical to + // before — a `text` field carrying a stray `reference` is still + // never read, and so still never refused. + const targetObject = referenceCarrierOf(fieldDef, 'SeedLoader.buildDependencyGraph'); + if (!targetObject) continue; // Track dependency ordering only for objects within the graph if (objectSet.has(targetObject) && !dependsOn.includes(targetObject)) { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b187b43aafe..4ca2839cae6 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -26,7 +26,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; // engine is what `metadata-protocol.validateData` returns, so letting the two // drift would put a translation layer between a verdict and its contract. import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api'; -import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. import { @@ -13093,7 +13093,24 @@ export class ObjectQL implements IObjectQLEngine { if (!childName || !fields) continue; for (const fdef of Object.values(fields)) { if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue; - const ref = fdef.reference; + // [#18550] The carrier is read through the ONE arbiter, so a + // `reference` no reader can read REFUSES here instead of reading as + // "this child does not reference `name`". The two answers stay + // different on purpose: + // + // - ABSENCE (`undefined` / `null` / `''`) is unchanged and still + // silent. `referenceCarrierOf` answers `undefined` for all three + // and this `continue` skips the field, which is what a field that + // names no target legitimately means (`FieldSchema.reference` is + // `.optional()`, `StrictField` declares it nullable). + // - UNREADABILITY is the loud one. An object- or array-valued + // carrier was TRUTHY here and then failed both name comparisons + // below, so the relation was dropped from the set silently — and + // this function's `'none'` is, by its own docblock above, the one + // verdict that asserts something POSITIVE about the schema + // ("nothing references this object"). An unreadable carrier can + // no more support that claim than an unreadable registry can. + const ref = referenceCarrierOf(fdef, 'ObjectQL.planCascadeAtomicity'); if (!ref) continue; let resolvedRef: string | undefined; try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } @@ -13541,7 +13558,14 @@ export class ObjectQL implements IObjectQLEngine { if (!childName || !fields) continue; for (const [fieldName, fdef] of Object.entries(fields)) { if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue; - const ref = fdef.reference; + // [#18550] Same arbiter, same absence-vs-unreadability split as + // {@link ObjectQL.planCascadeAtomicity} states above — and this is the + // seam where the silence was measurable end to end: an unreadable + // carrier made the relation invisible to the cascade, so `delete()` + // removed the parent, left a `master_detail` child behind, and + // reported success. No `restrict` refusal, no `set_null`, nothing + // logged. Absence still `continue`s here exactly as before. + const ref = referenceCarrierOf(fdef, 'ObjectQL.cascadeDeleteRelations'); if (!ref) continue; // Match the target object by raw or resolved name. let resolvedRef: string | undefined; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4571e491bae..2fc1306692d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -157,6 +157,7 @@ import { isApiOperationAllowed, API_PRIMITIVES, DATA_ACTION_TO_API_OPERATION, + referenceCarrierOf, } from '@objectstack/spec/data'; // [#8013] The SHARED envelope writer (#3973), aliased. [#9098] The alias no // longer exists to dodge a NAME collision — the local responder this used to @@ -10842,6 +10843,17 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); let referenceObject: string | undefined = picker.object; if (!referenceObject && typeof (p as any).getMetaItems === 'function') { + // [#18550] The field def is HOISTED out of the fetch's + // swallow and the carrier is read after it, deliberately. + // The `catch` below exists for the metadata fetch — a + // protocol that cannot answer leaves `referenceObject` + // unset and the route answers `500 LOOKUP_TARGET_MISSING` + // — and an unreadable carrier read INSIDE it would be + // swallowed by it and land on that same envelope, which + // is the conflation this card exists to end: "no target + // is declared" and "the declared target cannot be read" + // want different fixes from whoever owns the metadata. + let fieldDef: unknown; try { const objectsRequest: TransportScopedMetaRequest = { type: 'object', @@ -10850,7 +10862,6 @@ export class RestServer { const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; const obj = items.find((o: any) => o?.name === match.object); - const def = obj?.fields?.[fieldName]; // [#7486] Resolve the target from the canonical key — and, since // [#12920], from it ALONE. `reference` is the spelling `FieldSchema` // accepts, so it is the only spelling a field def can legitimately @@ -10886,8 +10897,21 @@ export class RestServer { // tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`), // replayed on stored-row rehydration — declared, tested and removable // on a schedule, which a `??` arm here never was. - referenceObject = def?.reference; + // + // [#18550] The canonical-key read itself now happens just BELOW this + // `catch`, through the one arbiter — see there for why it moved. + fieldDef = obj?.fields?.[fieldName]; } catch {/* ignore */} + // ABSENCE stays silent and unchanged: `undefined` / + // `null` / `''` all answer `undefined`, so the route + // falls to the `LOOKUP_TARGET_MISSING` refusal below + // exactly as before. UNREADABILITY throws past this + // handler's outer `catch`, which classifies and LOGS it + // (`mapDataError` + `logError`) rather than reporting a + // missing target — and it also stops an object-valued + // carrier from being forwarded as `query.object` into + // `findData`, which is what it did before this change. + referenceObject = referenceCarrierOf(fieldDef, 'REST public-form lookup picker'); } if (!referenceObject) { res.status(500).json({ diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index 8c0602eab96..10aee90ae8d 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -19,6 +19,7 @@ // is reported `blocked` with a precise reason — the gate stays honest. +import { referenceCarrierOf } from '@objectstack/spec/data'; import { declaredCollection } from './artifact-collections.js'; const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']); @@ -133,8 +134,16 @@ const REJECTED_REFERENCE_ALIASES = ['reference_to', 'referenceTo'] as const; * reports THAT. */ function relationTarget(f: any): string | null { - const ref = f?.reference; - return typeof ref === 'string' && ref.length > 0 ? ref : null; + // [#18550] The carrier read through the ONE arbiter, which is the same + // argument the docblock above makes for {@link rejectedReferenceAlias}, one + // step further: degrading an UNREADABLE carrier to the generic "has no + // `reference` target" is the trade this reader already refused to make for a + // rejected alias. The operator would read "this object could not be derived" + // and never learn the carrier was the reason. Absence keeps its answer — + // `undefined` / `null` / `''` all become `null` here, exactly as before, and + // the report line about an absent target is still a report line. + const ref = referenceCarrierOf(f, 'verify deriveCrudCases relationTarget'); + return ref ?? null; } /** From f971e783b719a1244e563e551c67697a2beb7e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:00:11 +0000 Subject: [PATCH 2/5] test(spec-carrier): pin the refusal AND the absence answer at each routed reader Every refusal case has an absence partner (`undefined` / `null` / `''`) and a positive control, so a harness that had stopped exercising the reader could not pass vacuously. The objectql suite also pins that nothing is written when the refusal fires, which is the measured defect inverted. The four lint sites take the form the already-routed lint readers use: the literal `.reference` read stays at the site so the #5017 receiver meta-test keeps its subject, and only the shape judgment moves to the arbiter. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .changeset/18550-reference-carrier-residue.md | 33 ++ .../lint/src/validate-expressions.test.ts | 59 ++++ packages/lint/src/validate-expressions.ts | 8 +- .../lint/src/validate-field-consumers.test.ts | 59 ++++ packages/lint/src/validate-field-consumers.ts | 4 +- .../src/validate-object-references.test.ts | 77 ++++ .../lint/src/validate-object-references.ts | 4 +- ...lidate-sharing-rule-enforceability.test.ts | 46 +++ .../validate-sharing-rule-enforceability.ts | 2 +- .../src/seed-loader-reference-carrier.test.ts | 118 +++++++ .../engine-cascade-reference-carrier.test.ts | 330 ++++++++++++++++++ .../src/public-form-lookup-picker.test.ts | 94 +++++ packages/verify/src/derive.test.ts | 57 +++ 13 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 .changeset/18550-reference-carrier-residue.md create mode 100644 packages/metadata-protocol/src/seed-loader-reference-carrier.test.ts create mode 100644 packages/objectql/src/engine-cascade-reference-carrier.test.ts diff --git a/.changeset/18550-reference-carrier-residue.md b/.changeset/18550-reference-carrier-residue.md new file mode 100644 index 00000000000..e283270a120 --- /dev/null +++ b/.changeset/18550-reference-carrier-residue.md @@ -0,0 +1,33 @@ +--- +"@objectstack/objectql": minor +"@objectstack/rest": minor +"@objectstack/metadata-protocol": minor +"@objectstack/lint": minor +"@objectstack/verify": minor +--- + +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. + +`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. + +**`@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`: + +``` +before acct=1 task=1 +delete RESOLVED true <- success reported to the caller +after acct=0 task=1 <- an ORPHANED master_detail row +``` + +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. + +**`@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. + +**`@objectstack/metadata-protocol`** — the seed dependency graph, which also retires an `as string` cast that asserted exactly what its truthiness guard had not checked. + +**`@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`). + +**`@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. + +`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. + +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. diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 5f53eddf340..0331bb694d3 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -4234,3 +4234,62 @@ describe("validateStackExpressions — a non-record entry in an object's `fields expect(issues[0].message).toContain('amount'); }); }); + +/** + * [#18550] `masterDetailCount` must refuse a `reference` carrier it cannot + * read, rather than counting the relationship as undeclared. + * + * One of the measured residue sites of ruling letter E item 2 on #18095. The + * count was `typeof ref === 'string' && ref.trim() !== ''`, which gives an + * UNREADABLE carrier the same answer as an ABSENT one — so an object whose + * master IS declared counted zero masters, and the `parent`-scope gate above + * reported "declares no `master_detail` relationships" about metadata that + * declares one. A finding about the wrong thing: the relationship is not + * missing, its target is unreadable, and the two want different fixes. + * + * Absence keeps its answer: `undefined`, `null`, `''` and a whitespace-only + * carrier all still count as no master, because none of them names an object. + */ +describe('masterDetailCount — an unreadable `reference` carrier is refused (#18550)', () => { + const lintObject = (obj: Record) => () => validateStackExpressions({ objects: [obj] }); + const detailOn = (carrier: Record) => ({ + name: 'inv_line', + fields: { + inv: { type: 'master_detail', ...carrier }, + qty: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + }, + }); + + it('control: a READABLE carrier counts as a master, so the `parent` gate stays silent', () => { + // Without this, every refusal below could pass on a gate that had stopped + // resolving masters at all. + expect(lintObject(detailOn({ reference: 'inv' }))()).toHaveLength(0); + }); + + it('an OBJECT-valued carrier REFUSES — ⛔ not a "declares no master_detail" finding', () => { + const run = lintObject(detailOn({ reference: { object: 'inv' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/validate-expressions masterDetailCount/); + expect(run).toThrow(/`reference` is an object/); + expect(run).toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it('an ARRAY-valued carrier refuses too, naming the shape it found', () => { + expect(lintObject(detailOn({ reference: ['inv', 'inv2'] }))).toThrow(/`reference` is an array \(length 2\)/); + }); + + // ── ABSENCE: still counted as no master, still reported as the `parent`-gate + // finding, ⛔ never thrown on. These are the cases a mechanical + // throw-on-falsy sweep would break. + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ['whitespace only (names no object either)', { reference: ' ' }], + ])('absence stays a FINDING, not a throw: %s', (_label, carrier) => { + const issues = lintObject(detailOn(carrier))(); + const parentScope = issues.filter((i) => /reads `parent`/.test(i.message)); + expect(parentScope).toHaveLength(1); + expect(parentScope[0]!.message).toMatch(/declares no `master_detail` relationships/); + }); +}); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 1925a44a27e..7c085e15ace 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -386,7 +386,13 @@ function masterDetailCount(obj: AnyRec): number { // the silence: an object-valued carrier made a declared `master_detail` // invisible to this count, so `parent` was judged unbound (or two masters // read as one) from metadata that does declare a relationship. - const ref = referenceCarrierOf(def, 'validate-expressions masterDetailCount'); + // ⭐ The literal `.reference` read STAYS here, in the argument, and only + // the SHAPE judgment moves out — the form `validate-security-posture.ts` + // and `data-model-rules.ts` already use, and for their stated reason: the + // #5017 receiver meta-test reads this rule's SOURCE to prove it reads + // `reference` and never an alias, and a read folded inside a helper call + // would disarm that scan silently. + const ref = referenceCarrierOf({ reference: def.reference }, 'validate-expressions masterDetailCount'); if (ref !== undefined && ref.trim() !== '') n += 1; } return n; diff --git a/packages/lint/src/validate-field-consumers.test.ts b/packages/lint/src/validate-field-consumers.test.ts index d54fad743c2..e9a4199e751 100644 --- a/packages/lint/src/validate-field-consumers.test.ts +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -471,3 +471,62 @@ describe('validateFieldConsumers (#15922)', () => { }); }); }); + +/** + * [#18550] The `displayField` consumer edge must refuse a `reference` carrier + * it cannot read, rather than recording no edge at all. + * + * One of the measured residue sites of ruling letter E item 2 on #18095. The + * read was `strName(field.reference)`, which answers `undefined` for an + * unreadable carrier exactly as it does for an absent one — so a field a + * lookup DOES display was recorded as consumed by nobody, and this rule then + * reported it as carrier-only. The ledger under-reported, and the finding + * pointed at the displayed field instead of the unreadable carrier. + * + * Absence keeps its answer: with no target there is no object to look a + * `displayField` up on, so no edge is recorded and nothing throws. + */ +describe('validateFieldConsumers — an unreadable `reference` carrier is refused (#18550)', () => { + const stackWith = (carrier: AnyRec): AnyRec => ({ + objects: [ + { name: 'crm_account', fields: { name: { type: 'text' }, legal_name: { type: 'text' } } }, + { + name: 'crm_contact', + fields: { + name: { type: 'text' }, + account: { type: 'lookup', displayField: 'legal_name', ...carrier }, + }, + }, + ], + // A consumer root OTHER than `objects` is this rule's entry condition + // (`hasConsumerRoot`): with only `objects` present it returns early and + // never walks a field, so a fixture without one would make every case + // below vacuous. + views: [{ name: 'contact_list', object: 'crm_contact', viewKind: 'list', columns: ['name'] }], + }); + + it('control: a READABLE carrier records the `displayField` edge, so the target is not carrier-only', () => { + const findings = validateFieldConsumers(stackWith({ reference: 'crm_account' })); + expect(findings.map((f) => f.path)).not.toContain('objects[0].fields.legal_name'); + }); + + it('an OBJECT-valued carrier REFUSES — ⛔ not a silent missing edge', () => { + const run = () => validateFieldConsumers(stackWith({ reference: { object: 'crm_account' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/validate-field-consumers walkObject/); + expect(run).toThrow(/`reference` is an object/); + expect(run).toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ])('absence records no edge and does NOT throw: %s', (_label, carrier) => { + // With no target there is no object to resolve `displayField` against, so + // the displayed field is genuinely unconsumed here — the rule's ordinary + // answer, reached without a throw. + const findings = validateFieldConsumers(stackWith(carrier as AnyRec)); + expect(findings.map((f) => f.path)).toContain('objects[0].fields.legal_name'); + }); +}); diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 50ed2767731..09f2a6dde64 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -555,7 +555,9 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj // so the `displayField` consumer edge below was never recorded and the // ledger under-reported — a field a lookup DOES display read as unused. // Absence still answers `undefined` and records nothing. - const reference = referenceCarrierOf(field, 'validate-field-consumers walkObject'); + // Same form as the sibling lint readers: the literal `.reference` read + // stays at the site, only the shape judgment moves to the arbiter. + const reference = referenceCarrierOf({ reference: field.reference }, 'validate-field-consumers walkObject'); const displayField = strName(field.displayField); if (reference && displayField && ledger.declares(reference, displayField)) { ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' }); diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index f199cbc1465..81f90e618f6 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -517,3 +517,80 @@ describe('validateObjectReferences — exemptions (false-positive floor)', () => expect(findings).toEqual([]); }); }); + +/** + * [#18550] Both of this rule's carrier reads must refuse a `reference` they + * cannot read, rather than saying nothing about it. + * + * Two of the measured residue sites of ruling letter E item 2 on #18095. Both + * read `strName(field.reference)` / `strName(param.reference)`, and `check` + * returns early on `undefined` — so the declaration that most needs a + * resolvable target reported NOTHING: not this rule's unknown-object error + * (there is no name to resolve), and not `relationship/missing-reference` + * either (the target is not missing). Refused where it was written by + * `ObjectSchema.safeParse`, read as absent where it was consumed, reported + * nowhere. + * + * Absence keeps its answer, and that answer is deliberately silence HERE: an + * absent target is `field/relationship-without-reference`'s subject (it names + * the field and prescribes the key), not this rule's. + */ +describe('validateObjectReferences — an unreadable `reference` carrier is refused (#18550)', () => { + const fieldCarrier = (carrier: Record) => ({ + ...baseStack(), + objects: [ + ...baseStack().objects, + { name: 'crm_contact', fields: { name: { type: 'text' }, account: { type: 'lookup', ...carrier } } }, + ], + }); + const paramCarrier = (carrier: Record) => ({ + ...baseStack(), + actions: [{ name: 'mass_reassign', params: [{ name: 'owner', type: 'lookup', ...carrier }] }], + }); + + it('control: a READABLE field carrier resolving to a known object is silent', () => { + expect(validateObjectReferences(fieldCarrier({ reference: 'crm_account' }))).toHaveLength(0); + }); + + it('control: a READABLE field carrier naming an UNKNOWN object still errors — the rule still works', () => { + const findings = validateObjectReferences(fieldCarrier({ reference: 'zzz_nope' })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN); + }); + + it('an OBJECT-valued FIELD carrier REFUSES — ⛔ not silence, and ⛔ not "unknown object"', () => { + const run = () => validateObjectReferences(fieldCarrier({ reference: { object: 'crm_account' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/validate-object-references field target/); + expect(run).toThrow(/`reference` is an object/); + expect(run).toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it('an OBJECT-valued ACTION-PARAM carrier refuses too, and the reader that could not read it is named', () => { + // `ActionParamSchema.reference`'s own docblock: the key name "deliberately + // mirrors `FieldSchema.reference` so the same spelling" carries the target + // object's name. One contract, so one reader. + const run = () => validateObjectReferences(paramCarrier({ reference: { object: 'sys_user' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/validate-object-references action param target/); + expect(run).toThrow(/`reference` is an object/); + }); + + it('control: a READABLE action-param carrier naming an unknown object still errors', () => { + const findings = validateObjectReferences(paramCarrier({ reference: 'user' })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('actions[0].params[0].reference'); + }); + + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ])('absence stays SILENT here and does not throw: %s', (_label, carrier) => { + // ⛔ Deliberate silence, not an oversight: an absent relationship target is + // reported by `field/relationship-without-reference`, which names the field + // and prescribes the key. This rule only judges targets that ARE named. + expect(validateObjectReferences(fieldCarrier(carrier))).toHaveLength(0); + expect(validateObjectReferences(paramCarrier(carrier))).toHaveLength(0); + }); +}); diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index 7a73f40bd2d..7bc785e4874 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -303,7 +303,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { // an absent target is `field/relationship-without-reference`'s subject, // not this rule's. check( - referenceCarrierOf(field, 'validate-object-references field target'), + referenceCarrierOf({ reference: field.reference }, 'validate-object-references field target'), `object "${objName}" · field "${fieldName}"`, `objects[${oi}].fields.${fieldName}.reference`, `${type} target`, @@ -326,7 +326,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { // "deliberately mirrors `FieldSchema.reference` so the same spelling" // carries the target object's name — one contract, so one reader. check( - referenceCarrierOf(param, 'validate-object-references action param target'), + referenceCarrierOf({ reference: param.reference }, 'validate-object-references action param target'), where, `${actionPath}.params[${pi}].reference`, 'record-picker target', diff --git a/packages/lint/src/validate-sharing-rule-enforceability.test.ts b/packages/lint/src/validate-sharing-rule-enforceability.test.ts index ad8f1524fcd..edbdec47b1b 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.test.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.test.ts @@ -528,3 +528,49 @@ describe('the hint names REAL sharing-model and depth vocabulary', () => { expect(new Set(ShareRecipientType.options).has('position')).toBe(true); }); }); + +/** + * [#18550] `masterOf` must refuse a `reference` carrier it cannot read, rather + * than reading the master_detail as naming no master. + * + * One of the measured residue sites of ruling letter E item 2 on #18095. The + * read was `typeof ref === 'string' && ref`, which gives an unreadable carrier + * the same answer as an absent one — so a `controlled_by_parent` detail whose + * master IS declared answered `undefined` here, and the fix-hint that names + * the master went out naming nothing. + * + * Absence keeps its answer: a `master_detail` that names no master leaves + * `masterOf` at `undefined`, and the finding is still produced. + */ +describe('validateSharingRuleEnforceability — an unreadable master carrier is refused (#18550)', () => { + const detailAnchoredOn = (carrier: Record) => + anchoredOn('controlled_by_parent', { + fields: { + name: { type: 'text', label: 'Name' }, + account: { type: 'master_detail', label: 'Account', ...carrier }, + }, + }); + + it('control: a READABLE master carrier still produces the controlled-by-parent finding', () => { + // Without this, the refusal below could pass on a rule that had stopped + // judging anchors at all. + expect(ids(detailAnchoredOn({ reference: 'crm_account' }))) + .toContain(SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT); + }); + + it('an OBJECT-valued carrier REFUSES — ⛔ not "this detail names no master"', () => { + const run = () => validateSharingRuleEnforceability(detailAnchoredOn({ reference: { object: 'crm_account' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/validate-sharing-rule-enforceability masterOf/); + expect(run).toThrow(/`reference` is an object/); + expect(run).toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ])('absence keeps the ordinary finding and does NOT throw: %s', (_label, carrier) => { + expect(ids(detailAnchoredOn(carrier))).toContain(SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT); + }); +}); diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index f2ef83cce21..9c7fd987f44 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.ts @@ -264,7 +264,7 @@ function masterOf(obj: AnyRec): string | undefined { // detail whose master IS declared answered `undefined` here and the // arm that needs the master went quiet. Absence still answers // `undefined` and falls through to the `return undefined` below. - const ref = referenceCarrierOf(f, 'validate-sharing-rule-enforceability masterOf'); + const ref = referenceCarrierOf({ reference: f.reference }, 'validate-sharing-rule-enforceability masterOf'); if (ref) return ref; } } diff --git a/packages/metadata-protocol/src/seed-loader-reference-carrier.test.ts b/packages/metadata-protocol/src/seed-loader-reference-carrier.test.ts new file mode 100644 index 00000000000..126e4ef412d --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-reference-carrier.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18550] `buildDependencyGraph` must refuse a `FieldSchema.reference` carrier + * it cannot read, rather than building a dependency graph around it. + * + * One of the measured residue sites of ruling letter E item 2 on #18095. The + * read was a truthiness gate followed by a CAST: + * + * if ((type is lookup|master_detail|user) && fieldDef.reference) { + * const targetObject = fieldDef.reference as string; + * + * The cast asserted exactly what the guard had not checked. An object-valued + * carrier is truthy, so `targetObject` became a non-string that matched no name + * in `objectSet` — contributing no `dependsOn` edge, so the seed order was + * computed as if the relationship did not exist — and was then pushed onto + * `references` for resolution to make of what it could. + * + * Absence keeps its answer, and there are three spellings of it: `undefined` + * (what `.optional()` admits), `null` (what `StrictField` admits) and `''` + * (which names no object). All three still skip the field silently, because a + * relational field naming no target is a legal thing for metadata to say — the + * defect that reports THAT is `field/relationship-without-reference`, not this + * reader. + * + * The type gate deliberately stays FIRST, so the set of fields whose carrier is + * read here is byte-identical to before: a `text` field carrying a stray + * `reference` was never read and so is still never refused. That case is pinned + * too — it is the boundary between "this reader got stricter" and "this reader + * got wider". + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader.js'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +/** The graph builder reads metadata only — `getSchema` is the whole surface. */ +function engineWith(schemas: Record): IDataEngine { + return { getSchema: vi.fn((name: string) => schemas[name]) } as unknown as IDataEngine; +} + +function emptyMetadata(): IMetadataService { + return { getObject: vi.fn(async () => undefined) } as unknown as IMetadataService; +} + +const author = { name: 'author', fields: { name: { type: 'text', required: true } } }; + +/** One `book` whose `primary_author` carrier is whatever the case supplies. */ +const bookWith = (carrier: Record) => ({ + name: 'book', + fields: { + name: { type: 'text', required: true }, + primary_author: { type: 'lookup', ...carrier }, + }, +}); + +const graphOver = (schemas: Record) => + new SeedLoaderService(engineWith(schemas), emptyMetadata(), createLogger()).buildDependencyGraph(['author', 'book']); + +describe('[#18550] seed dependency graph — an unreadable `reference` carrier is refused', () => { + it('control: a READABLE carrier builds the edge and the reference row', async () => { + // Without this every refusal below could pass on a builder that had + // stopped deriving relationships at all. + const graph = await graphOver({ author, book: bookWith({ reference: 'author' }) }); + const book = graph.nodes.find((n) => n.object === 'book'); + expect(book?.dependsOn).toEqual(['author']); + expect(book?.references.map((r) => r.targetObject)).toEqual(['author']); + expect(graph.insertOrder.indexOf('author')).toBeLessThan(graph.insertOrder.indexOf('book')); + }); + + it('an OBJECT-valued carrier refuses — the reader is named and the shape is named', async () => { + // ⛔ Not a bare `toThrow()`: a builder that threw some other Error on some + // other input would satisfy that. + const attempt = () => graphOver({ author, book: bookWith({ reference: { object: 'author' } }) }); + await expect(attempt()).rejects.toThrow(TypeError); + await expect(attempt()).rejects.toThrow(/SeedLoader\.buildDependencyGraph/); + await expect(attempt()).rejects.toThrow(/`reference` is an object/); + await expect(attempt()).rejects.toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it('an ARRAY-valued carrier refuses too, and the message names its length', async () => { + await expect(graphOver({ author, book: bookWith({ reference: ['author', 'co_author'] }) })) + .rejects.toThrow(/`reference` is an array \(length 2\)/); + }); + + // ── ABSENCE — all three spellings, none of which may throw. ⛔ These are the + // cases a mechanical "throw on everything falsy" sweep would break. + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ])('absence stays silent: %s derives no edge and does not throw', async (_label, carrier) => { + const graph = await graphOver({ author, book: bookWith(carrier) }); + const book = graph.nodes.find((n) => n.object === 'book'); + expect(book?.dependsOn).toEqual([]); + expect(book?.references).toEqual([]); + }); + + it('the type gate stays FIRST: a `text` field carrying a stray `reference` is not read, so not refused', async () => { + // The boundary case. This reader's job is relational targets; a carrier on + // a non-relational field was never read here and is not this change's to + // start refusing. (`ObjectSchema.safeParse` refuses the shape wherever it + // was written, on any field type.) + const strayCarrier = { + name: 'book', + fields: { + name: { type: 'text', required: true }, + note: { type: 'text', reference: { object: 'author' } }, + }, + }; + const graph = await graphOver({ author, book: strayCarrier }); + expect(graph.nodes.find((n) => n.object === 'book')?.references).toEqual([]); + }); +}); diff --git a/packages/objectql/src/engine-cascade-reference-carrier.test.ts b/packages/objectql/src/engine-cascade-reference-carrier.test.ts new file mode 100644 index 00000000000..72382a55655 --- /dev/null +++ b/packages/objectql/src/engine-cascade-reference-carrier.test.ts @@ -0,0 +1,330 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18550] The delete-cascade path's TWO `FieldSchema.reference` carrier reads + * must refuse a carrier they cannot read, rather than reading it as "this + * child does not reference the object being deleted". + * + * Ruling letter E item 2 on #18095 asked for the loud refusal at EVERY reader. + * PR #18503 delivered it at the arbiter (`referenceCarrierOf`) and the lint + * read sites; these two were the measured residue. Both used to read + * `fdef.reference` raw behind `if (!ref) continue`, which is correct for an + * ABSENT carrier and silent for an UNREADABLE one: an object- or array-valued + * `reference` is TRUTHY, so it passed that guard and then failed both name + * comparisons below it, and the relation dropped out of the cascade. + * + * ## The measured consequence, which is why this file exists + * + * With a `master_detail` child whose carrier is `{ object: 'acct' }`: + * + * before-delete acct=1 task=1 + * delete RESOLVED true <- success reported to the caller + * after-delete acct=0 task=1 <- an ORPHANED detail row + * + * No `restrict` refusal, no `set_null`, no `cascade`, nothing logged. The same + * probe against the routed reads refuses with the arbiter's `TypeError` and + * touches NO row, because `delete()` calls `planCascadeAtomicity` before it + * runs the cascade. + * + * ## Absence and unreadability are DIFFERENT answers, and both are pinned + * + * `FieldSchema.reference` is `z.string().optional()` and `StrictField` declares + * it nullable, so `undefined` / `null` / `''` say "this field names no target" + * — a legal thing for a field to say, and they must never throw. Only a + * carrier in a shape no reader can read refuses. Every refusal case here has + * an absence partner and a positive control, so a harness that had stopped + * cascading at all could not pass vacuously. + * + * ## Why the carrier is injected at the registry rather than authored + * + * The engine's own WRITE path already refuses this shape — `insert()` runs + * `assertReferencesResolve`, which asks `referenceTargetOf` and therefore the + * same arbiter — so a row in this shape cannot be created through the engine. + * It gets there the way the arbiter's docblock names: a value that never went + * through parse (a raw `registerObject`, a stored/artifact row). The child row + * is written straight through the driver for the same reason, and the two + * seams are told apart by WHICH registry read sees the unreadable carrier: + * `delete()` reads first for `planCascadeAtomicity` and second for + * `cascadeDeleteRelations`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const OWNER_PACKAGE = 'test-18550'; + +/** The carrier shape `FieldSchema` declares a string and no reader can read. */ +const UNREADABLE_CARRIER = { object: 'acct' } as unknown as string; + +const acct: ServiceObject = { + name: 'acct', + label: 'Account', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +/** A readable `master_detail` child — the shape every control below registers. */ +const taskReadable: ServiceObject = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + account: { + name: 'account', + label: 'Account', + type: 'master_detail' as const, + reference: 'acct', + required: true, + }, + }, +}; + +/** The same child with `reference` ABSENT — the other legal answer. */ +const taskAbsentCarrier = { + ...taskReadable, + fields: { + ...taskReadable.fields, + account: { name: 'account', label: 'Account', type: 'master_detail' as const, reference: null }, + }, +} as unknown as ServiceObject; + +/** The same child with an UNREADABLE carrier. */ +const taskUnreadable: ServiceObject = { + ...taskReadable, + fields: { + ...taskReadable.fields, + account: { + name: 'account', + label: 'Account', + type: 'master_detail' as const, + reference: UNREADABLE_CARRIER, + required: true, + }, + }, +}; + +/** A minimal in-memory driver: the failure under test is in the READ of the + * field def, before any driver call, so a driver that always succeeds is what + * makes the orphaned row visible. */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); + if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async upsert(o: string, data: Record) { + const id = data.id as string | undefined; + return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** Row count read straight out of the stub's store — never through the engine. */ +const rows = (stores: Map>>, object: string) => + stores.get(object)?.size ?? 0; + +/** + * Serve `swapped` from the engine's registry on its Nth `getAllObjects()` call, + * counted from this call, and the real (readable) objects on every other. + * + * `delete()` reads the registry twice — once for `planCascadeAtomicity`, then + * once for `cascadeDeleteRelations` — so `nth: 1` isolates the first seam and + * `nth: 2` the second with the atomicity plan already computed. Returns the + * live counter, so each test asserts how many reads the delete actually got to + * make rather than re-deriving it from the code under test. + */ +function swapObjectsOnNthRead( + engine: ObjectQL, + nth: number, + swapped: ServiceObject[], +): { reads: () => number } { + const registry = engine.registry as unknown as { + getAllObjects: (packageId?: string) => ServiceObject[]; + }; + const real = registry.getAllObjects.bind(registry); + let n = 0; + registry.getAllObjects = (packageId?: string): ServiceObject[] => { + n += 1; + return n === nth ? swapped : real(packageId); + }; + return { reads: () => n }; +} + +describe('[#18550] the delete cascade refuses an unreadable `reference` carrier', () => { + let engine: ObjectQL; + let stores: Map>>; + let driver: any; + + beforeEach(async () => { + engine = new ObjectQL(); + const stub = makeStubDriver(); + stores = stub.stores; + driver = stub.driver; + engine.registerDriver(stub.driver, true); + await engine.init(); + engine.registry.registerObject(acct, OWNER_PACKAGE); + engine.registry.registerObject(taskReadable, OWNER_PACKAGE); + }); + + /** A parent with one detail row, the row written through the DRIVER. */ + async function parentWithDetail(): Promise { + const a = await engine.insert('acct', { name: 'Acme' }); + await driver.create('task', { id: 'task_1', title: 'Follow up', account: a.id }); + expect(rows(stores, 'acct')).toBe(1); + expect(rows(stores, 'task')).toBe(1); + return a.id as string; + } + + // ── POSITIVE CONTROLS ─────────────────────────────────────────────────── + // Without these, every refusal below could pass on a harness that no + // longer cascades at all. + + it('control: a READABLE carrier still cascades the detail row away', async () => { + const id = await parentWithDetail(); + await engine.delete('acct', { where: { id } } as any); + expect(rows(stores, 'acct')).toBe(0); + expect(rows(stores, 'task')).toBe(0); + }); + + it('control: an ABSENT carrier (`reference: null`) does NOT throw — absence is legal', async () => { + // `StrictField` declares `reference` nullable and `FieldSchema` has it + // `.optional()`, so this says "this field names no target". The cascade + // must skip the relation exactly as it did before the routing: the + // parent goes, the row that references nothing stays, and NOTHING is + // thrown. ⛔ This is the case a mechanical "throw on everything falsy" + // sweep would break. + const id = await parentWithDetail(); + engine.registry.registerObject(taskAbsentCarrier, OWNER_PACKAGE); + + await expect(engine.delete('acct', { where: { id } } as any)).resolves.toBeTruthy(); + expect(rows(stores, 'acct')).toBe(0); + expect(rows(stores, 'task')).toBe(1); + }); + + // ── SEAM 1 — `planCascadeAtomicity`, the FIRST of the delete's two reads. + + it('seam 1 (planCascadeAtomicity): an unreadable carrier refuses the delete BEFORE any row is touched', async () => { + const id = await parentWithDetail(); + const probe = swapObjectsOnNthRead(engine, 1, [acct, taskUnreadable]); + + const err: any = await engine.delete('acct', { where: { id } } as any).catch((e) => e); + + // ⛔ Not a bare "it threw": an unrepaired reader throwing some other + // Error on some other input would satisfy that. The class, the reader + // that could not read it, and the sentence the author reads are all + // asserted. + expect(err).toBeInstanceOf(TypeError); + expect(err.message).toContain('ObjectQL.planCascadeAtomicity'); + expect(err.message).toMatch(/`reference` is an object/); + expect(err.message).toMatch(/FieldSchema declares it as an optional STRING/); + // The refusal is the FIRST thing the delete does, so the parent is + // still there — this is the half that makes it a refusal rather than a + // partially-applied delete. + expect(probe.reads()).toBe(1); + expect(rows(stores, 'acct')).toBe(1); + expect(rows(stores, 'task')).toBe(1); + }); + + // ── SEAM 2 — `cascadeDeleteRelations`, the SECOND read, reached with the + // atomicity plan already computed from a readable registry. + + it('seam 2 (cascadeDeleteRelations): an unreadable carrier refuses instead of leaving an orphan', async () => { + const id = await parentWithDetail(); + const probe = swapObjectsOnNthRead(engine, 2, [acct, taskUnreadable]); + + const err: any = await engine.delete('acct', { where: { id } } as any).catch((e) => e); + + expect(err).toBeInstanceOf(TypeError); + expect(err.message).toContain('ObjectQL.cascadeDeleteRelations'); + expect(err.message).toMatch(/`reference` is an object/); + // Read #2 is where it stopped, which is what separates this seam from + // seam 1 above. + expect(probe.reads()).toBe(2); + // The measured defect, inverted: the detail row is NOT orphaned behind + // a successful delete. + expect(rows(stores, 'task')).toBe(1); + }); + + it('seam 2: an ARRAY carrier refuses too, and the message names the shape it found', async () => { + const id = await parentWithDetail(); + const arrayCarrier: ServiceObject = { + ...taskReadable, + fields: { + ...taskReadable.fields, + account: { + name: 'account', label: 'Account', type: 'master_detail' as const, + reference: ['acct', 'other'] as unknown as string, required: true, + }, + }, + }; + swapObjectsOnNthRead(engine, 2, [acct, arrayCarrier]); + + const err: any = await engine.delete('acct', { where: { id } } as any).catch((e) => e); + expect(err).toBeInstanceOf(TypeError); + expect(err.message).toMatch(/`reference` is an array \(length 2\)/); + }); + + it("control: an EMPTY-STRING carrier is absence, not a wrong shape — it does not throw", async () => { + // `''` names no object, so the arbiter answers `undefined` for it and + // this seam skips the relation. The third spelling of absence, pinned + // because it is the one a mechanical falsy-sweep gets wrong last. + const id = await parentWithDetail(); + const emptyCarrier: ServiceObject = { + ...taskReadable, + fields: { + ...taskReadable.fields, + account: { name: 'account', label: 'Account', type: 'master_detail' as const, reference: '' }, + }, + }; + swapObjectsOnNthRead(engine, 2, [acct, emptyCarrier]); + + await expect(engine.delete('acct', { where: { id } } as any)).resolves.toBeTruthy(); + expect(rows(stores, 'acct')).toBe(0); + }); +}); diff --git a/packages/rest/src/public-form-lookup-picker.test.ts b/packages/rest/src/public-form-lookup-picker.test.ts index 367d3a4788d..79211ec2624 100644 --- a/packages/rest/src/public-form-lookup-picker.test.ts +++ b/packages/rest/src/public-form-lookup-picker.test.ts @@ -540,3 +540,97 @@ describe('#13137 `FieldSchema` REFUSES the legacy target spellings, it does not }); } }); + +/** + * [#18550] The picker's field-def fallback must refuse a `reference` carrier it + * cannot READ, rather than reporting the target as MISSING. + * + * This was one of the measured residue sites of ruling letter E item 2 on + * #18095: `referenceObject = def?.reference` read the carrier raw, INSIDE the + * metadata fetch's `catch {}`. Two things followed from that, and both are + * pinned below. + * + * - An object-valued carrier is TRUTHY, so it passed the + * `if (!referenceObject)` gate and was forwarded verbatim as + * `query.object` into `findData` — the route asked the data layer to search + * an object whose name was an object. + * - Moving the read through the arbiter alone would not have been enough: + * inside that `catch` the refusal would have been swallowed and the route + * would have answered `500 LOOKUP_TARGET_MISSING` — "no target is declared" + * — for a def that declares one this reader cannot read. The two want + * different fixes from whoever owns the metadata, so the field def is + * hoisted out of the swallow and the carrier is read after it. + * + * Absence keeps its answer: `undefined`, `null` and `''` all still reach + * `LOOKUP_TARGET_MISSING`, which is the envelope this route has always used to + * say "nothing names the target". + */ +describe('#18550 an UNREADABLE `reference` carrier is refused, not reported as a missing target', () => { + const NO_OBJECT_PICKER = { displayFields: ['name', 'email'], maxResults: 10 }; + const savedWithoutObject = () => persistedBody(studioForm([{ field: 'owner', publicPicker: NO_OBJECT_PICKER }])); + const ownerDefIs = (ownerDef: unknown) => ({ ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } }); + + it('an object-valued carrier does NOT answer LOOKUP_TARGET_MISSING, and never reaches findData', async () => { + const stored = await savedWithoutObject(); + // The engine holds a row a resolving route WOULD return, so the red + // state is a 200 carrying data rather than an empty 200. + const { findData, lookup } = routesOver( + stored, + [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], + ownerDefIs({ type: 'lookup', reference: { object: 'sys_user' }, label: 'Owner' }), + ); + const res = mockRes(); + await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); + + // ⛔ The load-bearing NEGATIVE, and the whole point of hoisting the def + // out of the fetch's swallow: an unreadable carrier must not be + // reported as an absent one. + expect(res.body.code).not.toBe('LOOKUP_TARGET_MISSING'); + // What it IS instead, measured: the handler's outer `catch` classifies + // the throw and `logError`s it, so the carrier's unreadability reaches + // the operator's log and the caller gets the sanitised fault envelope + // (#5437/#7543 — a crash's `TypeError: …` text is never disclosed to + // the caller). ⚠️ The refusal is loud in the LOG; on the wire it is a + // 500 that is merely DISTINGUISHABLE from the missing-target 500. + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('INTERNAL_ERROR'); + // …and the object-valued carrier is never forwarded as `query.object`. + expect(findData).not.toHaveBeenCalled(); + }); + + it('control: an ABSENT carrier is STILL LOOKUP_TARGET_MISSING — the envelope absence has always had', async () => { + const stored = await savedWithoutObject(); + const { findData, lookup } = routesOver( + stored, + [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], + ownerDefIs({ type: 'lookup', label: 'Owner' }), + ); + const res = mockRes(); + await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); + expect(findData).not.toHaveBeenCalled(); + }); + + it('control: a NULL carrier is absence too (`StrictField` declares it nullable) — same envelope, no throw', async () => { + const stored = await savedWithoutObject(); + const { lookup } = routesOver(stored, [], ownerDefIs({ type: 'lookup', reference: null, label: 'Owner' })); + const res = mockRes(); + await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); + + expect(res.statusCode).toBe(500); + expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); + }); + + it('control: the canonical STRING carrier still resolves and answers 200 — the routing did not break the live path', async () => { + const stored = await savedWithoutObject(); + const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); + const res = mockRes(); + await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); + + expect(res.statusCode).toBe(200); + expect(findData).toHaveBeenCalledTimes(1); + expect(findData.mock.calls[0][0].object).toBe('sys_user'); + }); +}); diff --git a/packages/verify/src/derive.test.ts b/packages/verify/src/derive.test.ts index 0016a60d98b..00f821edd68 100644 --- a/packages/verify/src/derive.test.ts +++ b/packages/verify/src/derive.test.ts @@ -164,3 +164,60 @@ describe('deriveCrudCases — rejected `reference` aliases are narrowed AND name expect(c?.blocked).not.toMatch(/rejected alias/i); }); }); + +/** + * [#18550] `relationTarget` must refuse a `reference` carrier it cannot read, + * rather than degrading it to the generic "has no `reference` target". + * + * One of the measured residue sites of ruling letter E item 2 on #18095, and + * the same argument the #13250 suite above makes for a rejected ALIAS, one step + * further. That narrowing was only safe because the report says WHY; an + * UNREADABLE carrier had no such partner, so it fell into the generic sentence + * an object with no relationship metadata at all receives. The operator would + * read "this object could not be derived" and never learn the carrier was the + * reason — one silent seam traded for another (#5262's defect). + * + * Absence keeps its answer, and keeps it as a REPORT LINE: `undefined`, `null` + * and `''` all still derive `null` and still produce the generic reason, which + * is the thing this deriver is for. + */ +describe('deriveCrudCases — an UNREADABLE `reference` carrier is refused (#18550)', () => { + const withRef = (fieldDef: Record) => ({ + objects: [ + { name: 'company', fields: { title: { type: 'text' } } }, + { name: 'contact', fields: { company_id: fieldDef } }, + ], + }); + + it('control: the canonical STRING carrier still derives its target', () => { + const c = deriveCrudCases( + withRef({ type: 'lookup', required: true, reference: 'company' }), + ).find((x) => x.object === 'contact'); + expect(c?.blocked).toBeFalsy(); + }); + + it('an OBJECT-valued carrier REFUSES — ⛔ not a generic "has no `reference` target" block', () => { + const run = () => deriveCrudCases(withRef({ type: 'lookup', required: true, reference: { object: 'company' } })); + expect(run).toThrow(TypeError); + expect(run).toThrow(/verify deriveCrudCases relationTarget/); + expect(run).toThrow(/`reference` is an object/); + expect(run).toThrow(/FieldSchema declares it as an optional STRING/); + }); + + it('an ARRAY-valued carrier refuses too, naming the shape it found', () => { + expect(() => deriveCrudCases(withRef({ type: 'lookup', required: true, reference: ['company', 'firm'] }))) + .toThrow(/`reference` is an array \(length 2\)/); + }); + + it.each([ + ['undefined (the key omitted)', {}], + ['null (`StrictField` declares it nullable)', { reference: null }], + ["'' (names no object)", { reference: '' }], + ])('absence stays a REPORT LINE, never a throw: %s', (_label, carrier) => { + const blocked = deriveCrudCases( + withRef({ type: 'lookup', required: true, ...carrier }), + ).find((x) => x.object === 'contact'); + expect(blocked?.blocked).toMatch(/has no `reference` target/); + expect(blocked?.blocked).not.toMatch(/rejected alias/i); + }); +}); From dd6f41c1d8f1757974d9d040e767b24949c74e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:24:26 +0000 Subject: [PATCH 3/5] test(objectql): the new cascade double applies the caller's bound, by presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:objectql-double-limit` graded the new file's `find` double limit-blind. Fixed at the double — the baseline never grows. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .../objectql/src/engine-cascade-reference-carrier.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine-cascade-reference-carrier.test.ts b/packages/objectql/src/engine-cascade-reference-carrier.test.ts index 72382a55655..e482e0e482f 100644 --- a/packages/objectql/src/engine-cascade-reference-carrier.test.ts +++ b/packages/objectql/src/engine-cascade-reference-carrier.test.ts @@ -131,7 +131,11 @@ function makeStubDriver() { name: 'memory', version: '0.0.0', supports: {}, async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(o: string, ast: any) { - return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const matched = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound, applied AFTER the filter and by PRESENCE + // (`check:objectql-double-limit`): a double that silently ignores a + // `limit` it was handed cannot witness a paged read at all. + return typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; }, async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; From bd1b3dcad973e643f6b1226805fa71ad856745df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:28:47 +0000 Subject: [PATCH 4/5] refactor(verify): keep the literal `.reference` read beside the alias list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relationTarget`'s subject is WHICH KEY it reads, stated at length in its own docblock, so the read stays greppable in the function body and only the shape judgment moves to the arbiter — the same form the lint readers use. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- packages/verify/src/derive.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index 10aee90ae8d..552dcb621f9 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -142,7 +142,10 @@ function relationTarget(f: any): string | null { // and never learn the carrier was the reason. Absence keeps its answer — // `undefined` / `null` / `''` all become `null` here, exactly as before, and // the report line about an absent target is still a report line. - const ref = referenceCarrierOf(f, 'verify deriveCrudCases relationTarget'); + // The literal `.reference` read stays in the argument, so this function's + // subject — WHICH KEY it reads — is still greppable here beside + // {@link REJECTED_REFERENCE_ALIASES}; only the shape judgment moves out. + const ref = referenceCarrierOf({ reference: f?.reference }, 'verify deriveCrudCases relationTarget'); return ref ?? null; } From a17094d9482575b014edce04224a0426e4e93712 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:42:13 +0000 Subject: [PATCH 5/5] docs(ui): the public-form picker's unreadable-carrier answer, named on the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route's error table enumerated four outcomes and now has a fifth: a stored `reference` holding a non-string declares a target the route cannot read, and answers the sanitised fault rather than LOOKUP_TARGET_MISSING. The page said "could not be resolved from the field definition" for the missing-target code, which read as covering both — the distinction this routing exists to make. Found by hand, not by the drift bot: `rest-server.ts` yields no doc anchor, so pages documenting it are invisible to that run by its own declaration. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- content/docs/ui/forms.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index ef26e5779bc..ca4a78ea958 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -257,7 +257,7 @@ sections: [{ | `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']`. | | `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. | | `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. | -| `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. | +| `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. | Those four keys are the whole block. It admits exactly what the route enforces — an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse @@ -285,6 +285,7 @@ Errors: | `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 | | `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view | | `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. | +| `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. | ### Auth model