Skip to content

Commit 3328088

Browse files
committed
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 <noreply@anthropic.com>
1 parent 0ec8185 commit 3328088

8 files changed

Lines changed: 121 additions & 20 deletions

File tree

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

Lines changed: 11 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,16 @@ 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+
const ref = referenceCarrierOf(def, 'validate-expressions masterDetailCount');
390+
if (ref !== undefined && ref.trim() !== '') n += 1;
382391
}
383392
return n;
384393
}

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

Lines changed: 7 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,12 @@ 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+
const reference = referenceCarrierOf(field, 'validate-field-consumers walkObject');
553559
const displayField = strName(field.displayField);
554560
if (reference && displayField && ledger.declares(reference, displayField)) {
555561
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });

‎packages/lint/src/validate-object-references.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
isPlatformProvidedObjectName,
8080
PLATFORM_PROVIDED_OBJECT_NAMES,
8181
} from '@objectstack/spec/system';
82+
import { referenceCarrierOf } from '@objectstack/spec/data';
8283

8384
import { recordsOf, suggestName } from './object-graph.js';
8485

@@ -293,8 +294,16 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] {
293294
const type = strName(field.type);
294295
if (!type || !RELATIONSHIP_TARGET_FIELD_TYPES.has(type)) continue;
295296
const fieldName = strName(field.name) ?? `#${fi}`;
297+
// [#18550] Through the ONE arbiter. `strName` gave an unreadable carrier
298+
// the same answer as an absent one, and `check` returns early on
299+
// `undefined` — so the field that most needs a target reported NOTHING:
300+
// not `relationship/missing-reference` (the target is not missing), and
301+
// not this rule's unknown-object error either. Absence still answers
302+
// `undefined` and this rule still, deliberately, says nothing about it —
303+
// an absent target is `field/relationship-without-reference`'s subject,
304+
// not this rule's.
296305
check(
297-
strName(field.reference),
306+
referenceCarrierOf(field, 'validate-object-references field target'),
298307
`object "${objName}" · field "${fieldName}"`,
299308
`objects[${oi}].fields.${fieldName}.reference`,
300309
`${type} target`,
@@ -312,8 +321,12 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] {
312321
if (!param || typeof param !== 'object') continue;
313322
const paramLabel = strName(param.name) ?? strName(param.field) ?? `#${pi}`;
314323
const where = `${actionLabel} · param "${paramLabel}"`;
324+
// [#18550] Same arbiter, same split. The carrier here is
325+
// `ActionParamSchema.reference`, whose own docblock says the key name
326+
// "deliberately mirrors `FieldSchema.reference` so the same spelling"
327+
// carries the target object's name — one contract, so one reader.
315328
check(
316-
strName(param.reference),
329+
referenceCarrierOf(param, 'validate-object-references action param target'),
317330
where,
318331
`${actionPath}.params[${pi}].reference`,
319332
'record-picker target',

‎packages/lint/src/validate-sharing-rule-enforceability.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
*/
138138

139139
import { compileCelToFilter } from '@objectstack/formula';
140+
import { referenceCarrierOf } from '@objectstack/spec/data';
140141
import { recordsOf } from './object-graph.js';
141142

142143
/** A `condition` outside the pushdown subset — the rule is never seeded. */
@@ -258,8 +259,13 @@ function effectiveSharingModelOf(obj: AnyRec): 'private' | 'read' | 'public' {
258259
function masterOf(obj: AnyRec): string | undefined {
259260
for (const f of recordsOf(obj.fields)) {
260261
if (f.type === 'master_detail') {
261-
const ref = f.reference;
262-
if (typeof ref === 'string' && ref) return ref;
262+
// [#18550] Through the ONE arbiter. An unreadable carrier used to read
263+
// as "this master_detail names no master", so a `controlled_by_parent`
264+
// detail whose master IS declared answered `undefined` here and the
265+
// arm that needs the master went quiet. Absence still answers
266+
// `undefined` and falls through to the `return undefined` below.
267+
const ref = referenceCarrierOf(f, 'validate-sharing-rule-enforceability masterOf');
268+
if (ref) return ref;
263269
}
264270
}
265271
return undefined;

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
SeedLoadResultParsed,
1414
Seed,
1515
} from '@objectstack/spec/data';
16-
import { SeedLoaderConfigSchema, isMultiValueField } from '@objectstack/spec/data';
16+
import { SeedLoaderConfigSchema, isMultiValueField, referenceCarrierOf } from '@objectstack/spec/data';
1717
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
1818
import { resolveSeedRecord } from '@objectstack/formula';
1919
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core';
@@ -696,11 +696,21 @@ export class SeedLoaderService implements ISeedLoaderService {
696696
if (objDef && objDef.fields) {
697697
const fields = objDef.fields as Record<string, any>;
698698
for (const [fieldName, fieldDef] of Object.entries(fields)) {
699-
if (
700-
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') &&
701-
fieldDef.reference
702-
) {
703-
const targetObject = fieldDef.reference as string;
699+
if (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') {
700+
// [#18550] The carrier goes through the ONE arbiter, which also
701+
// retires the `as string` cast this read used to carry — the cast
702+
// asserted exactly what the truthiness test had not checked, so an
703+
// object-valued carrier became a `targetObject` that matched no
704+
// name in `objectSet`, contributed no `dependsOn` edge, and was
705+
// then pushed onto `references` for resolution to make of what it
706+
// could. ABSENCE is unchanged: `undefined` / `null` / `''` answer
707+
// `undefined` and the field is skipped, which is what a relational
708+
// field naming no target means. The type gate stays FIRST so the
709+
// set of fields whose carrier is read here is byte-identical to
710+
// before — a `text` field carrying a stray `reference` is still
711+
// never read, and so still never refused.
712+
const targetObject = referenceCarrierOf(fieldDef, 'SeedLoader.buildDependencyGraph');
713+
if (!targetObject) continue;
704714

705715
// Track dependency ordering only for objects within the graph
706716
if (objectSet.has(targetObject) && !dependsOn.includes(targetObject)) {

‎packages/objectql/src/engine.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
2626
// engine is what `metadata-protocol.validateData` returns, so letting the two
2727
// drift would put a translation layer between a verdict and its contract.
2828
import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api';
29-
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';
29+
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';
3030
// [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1)
3131
// runs, so `FilterArray` has exactly one lowering in the product.
3232
import {
@@ -13093,7 +13093,24 @@ export class ObjectQL implements IObjectQLEngine {
1309313093
if (!childName || !fields) continue;
1309413094
for (const fdef of Object.values(fields)) {
1309513095
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
13096-
const ref = fdef.reference;
13096+
// [#18550] The carrier is read through the ONE arbiter, so a
13097+
// `reference` no reader can read REFUSES here instead of reading as
13098+
// "this child does not reference `name`". The two answers stay
13099+
// different on purpose:
13100+
//
13101+
// - ABSENCE (`undefined` / `null` / `''`) is unchanged and still
13102+
// silent. `referenceCarrierOf` answers `undefined` for all three
13103+
// and this `continue` skips the field, which is what a field that
13104+
// names no target legitimately means (`FieldSchema.reference` is
13105+
// `.optional()`, `StrictField` declares it nullable).
13106+
// - UNREADABILITY is the loud one. An object- or array-valued
13107+
// carrier was TRUTHY here and then failed both name comparisons
13108+
// below, so the relation was dropped from the set silently — and
13109+
// this function's `'none'` is, by its own docblock above, the one
13110+
// verdict that asserts something POSITIVE about the schema
13111+
// ("nothing references this object"). An unreadable carrier can
13112+
// no more support that claim than an unreadable registry can.
13113+
const ref = referenceCarrierOf(fdef, 'ObjectQL.planCascadeAtomicity');
1309713114
if (!ref) continue;
1309813115
let resolvedRef: string | undefined;
1309913116
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
@@ -13541,7 +13558,14 @@ export class ObjectQL implements IObjectQLEngine {
1354113558
if (!childName || !fields) continue;
1354213559
for (const [fieldName, fdef] of Object.entries(fields)) {
1354313560
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
13544-
const ref = fdef.reference;
13561+
// [#18550] Same arbiter, same absence-vs-unreadability split as
13562+
// {@link ObjectQL.planCascadeAtomicity} states above — and this is the
13563+
// seam where the silence was measurable end to end: an unreadable
13564+
// carrier made the relation invisible to the cascade, so `delete()`
13565+
// removed the parent, left a `master_detail` child behind, and
13566+
// reported success. No `restrict` refusal, no `set_null`, nothing
13567+
// logged. Absence still `continue`s here exactly as before.
13568+
const ref = referenceCarrierOf(fdef, 'ObjectQL.cascadeDeleteRelations');
1354513569
if (!ref) continue;
1354613570
// Match the target object by raw or resolved name.
1354713571
let resolvedRef: string | undefined;

‎packages/rest/src/rest-server.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ import {
157157
isApiOperationAllowed,
158158
API_PRIMITIVES,
159159
DATA_ACTION_TO_API_OPERATION,
160+
referenceCarrierOf,
160161
} from '@objectstack/spec/data';
161162
// [#8013] The SHARED envelope writer (#3973), aliased. [#9098] The alias no
162163
// longer exists to dodge a NAME collision — the local responder this used to
@@ -10842,6 +10843,17 @@ export class RestServer {
1084210843
const p = await this.resolveProtocol(environmentId, req);
1084310844
let referenceObject: string | undefined = picker.object;
1084410845
if (!referenceObject && typeof (p as any).getMetaItems === 'function') {
10846+
// [#18550] The field def is HOISTED out of the fetch's
10847+
// swallow and the carrier is read after it, deliberately.
10848+
// The `catch` below exists for the metadata fetch — a
10849+
// protocol that cannot answer leaves `referenceObject`
10850+
// unset and the route answers `500 LOOKUP_TARGET_MISSING`
10851+
// — and an unreadable carrier read INSIDE it would be
10852+
// swallowed by it and land on that same envelope, which
10853+
// is the conflation this card exists to end: "no target
10854+
// is declared" and "the declared target cannot be read"
10855+
// want different fixes from whoever owns the metadata.
10856+
let fieldDef: unknown;
1084510857
try {
1084610858
const objectsRequest: TransportScopedMetaRequest<GetMetaItemsRequest> = {
1084710859
type: 'object',
@@ -10850,7 +10862,6 @@ export class RestServer {
1085010862
const r: any = await p.getMetaItems(objectsRequest);
1085110863
const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1085210864
const obj = items.find((o: any) => o?.name === match.object);
10853-
const def = obj?.fields?.[fieldName];
1085410865
// [#7486] Resolve the target from the canonical key — and, since
1085510866
// [#12920], from it ALONE. `reference` is the spelling `FieldSchema`
1085610867
// accepts, so it is the only spelling a field def can legitimately
@@ -10886,8 +10897,21 @@ export class RestServer {
1088610897
// tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`),
1088710898
// replayed on stored-row rehydration — declared, tested and removable
1088810899
// on a schedule, which a `??` arm here never was.
10889-
referenceObject = def?.reference;
10900+
//
10901+
// [#18550] The canonical-key read itself now happens just BELOW this
10902+
// `catch`, through the one arbiter — see there for why it moved.
10903+
fieldDef = obj?.fields?.[fieldName];
1089010904
} catch {/* ignore */}
10905+
// ABSENCE stays silent and unchanged: `undefined` /
10906+
// `null` / `''` all answer `undefined`, so the route
10907+
// falls to the `LOOKUP_TARGET_MISSING` refusal below
10908+
// exactly as before. UNREADABILITY throws past this
10909+
// handler's outer `catch`, which classifies and LOGS it
10910+
// (`mapDataError` + `logError`) rather than reporting a
10911+
// missing target — and it also stops an object-valued
10912+
// carrier from being forwarded as `query.object` into
10913+
// `findData`, which is what it did before this change.
10914+
referenceObject = referenceCarrierOf(fieldDef, 'REST public-form lookup picker');
1089110915
}
1089210916
if (!referenceObject) {
1089310917
res.status(500).json({

‎packages/verify/src/derive.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
// is reported `blocked` with a precise reason — the gate stays honest.
2020

2121

22+
import { referenceCarrierOf } from '@objectstack/spec/data';
2223
import { declaredCollection } from './artifact-collections.js';
2324

2425
const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']);
@@ -133,8 +134,16 @@ const REJECTED_REFERENCE_ALIASES = ['reference_to', 'referenceTo'] as const;
133134
* reports THAT.
134135
*/
135136
function relationTarget(f: any): string | null {
136-
const ref = f?.reference;
137-
return typeof ref === 'string' && ref.length > 0 ? ref : null;
137+
// [#18550] The carrier read through the ONE arbiter, which is the same
138+
// argument the docblock above makes for {@link rejectedReferenceAlias}, one
139+
// step further: degrading an UNREADABLE carrier to the generic "has no
140+
// `reference` target" is the trade this reader already refused to make for a
141+
// rejected alias. The operator would read "this object could not be derived"
142+
// and never learn the carrier was the reason. Absence keeps its answer —
143+
// `undefined` / `null` / `''` all become `null` here, exactly as before, and
144+
// the report line about an absent target is still a report line.
145+
const ref = referenceCarrierOf(f, 'verify deriveCrudCases relationTarget');
146+
return ref ?? null;
138147
}
139148

140149
/**

0 commit comments

Comments
 (0)