From fe5b03c501fecdbb94844020e7b9fc658faa25c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:43:54 +0000 Subject: [PATCH 1/2] fix(spec): FieldSchema requires a non-empty reference on lookup/master_detail superRefine on the relationship types (ADR-0049 declared = enforced): a lookup / master_detail whose reference is missing or '' is refused at parse with a prescriptive message on the reference path. TSDoc aligned; pins for missing/empty/legal on both types; the three key-absence sample fixtures that used a bare lookup move to legal shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- ...lookup-master-detail-reference-required.md | 39 +++++++++ .../field-autonumber-default-format.test.ts | 7 +- packages/spec/src/data/field.test.ts | 84 ++++++++++++++++++- packages/spec/src/data/field.zod.ts | 34 +++++++- 4 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 .changeset/lookup-master-detail-reference-required.md diff --git a/.changeset/lookup-master-detail-reference-required.md b/.changeset/lookup-master-detail-reference-required.md new file mode 100644 index 0000000000..20004b9ca6 --- /dev/null +++ b/.changeset/lookup-master-detail-reference-required.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `FieldSchema` requires a non-empty `reference` on `lookup` / `master_detail` (#13632) + +**BREAKING** accept-set narrowing on `FieldSchema`, shipped as `minor` under the +repo's launch-window convention for breaking changes — the same grade the nearest +tightening precedents shipped with: #11519 / #11842 (`ActionSchema` accept-set +narrowings) and #13733 (`FormViewSchema` wizard tightening), all `minor` with the +**BREAKING** header. + +The key's own TSDoc has always called `reference` **required** on the two +relationship types, but the schema accepted a `lookup` / `master_detail` with the +key missing or set to `''` — a relationship that points nowhere. Nothing +downstream can act on that shape: the record picker has no object to query, +`$expand` has nothing to resolve, `deleteBehavior` has no parent to apply to, and +driver-mongodb silently skips the relationship index it would otherwise build. +Lint's `relationship/missing-reference` already grades the same hole an +error-severity finding; the publish seam was the one door left open — exactly +where AI-authored metadata that omits the key would otherwise parse cleanly and +fail far from the cause (ADR-0049 declared = enforced). + +What newly gets rejected: `type: 'lookup'` or `type: 'master_detail'` with +`reference` absent or `''`. The rejection is prescriptive on the `reference` +path — it names the type, the key, the expected shape (a snake_case target +object name), and the fix. Everything else is untouched: a non-empty `reference` +round-trips byte-identically, non-relationship types never carried the +requirement, `referenceVia` stays text-only and mutually exclusive with +`reference`, and the `Field.lookup()` / `Field.masterDetail()` helpers already +take the target as their first positional argument, so helper-authored fields +cannot miss it. + +The measured population of affected authored sources is zero in every in-tree +corpus (examples, reference apps, packaged metadata, seeds, structured metadata +and docs samples all declare targets; the census and its positive controls are +recorded on the PR). No key is removed or renamed, so there is no ADR-0087 +registry entry — the key stays authorable with the same meaning; only the +missing/empty hole closes. diff --git a/packages/spec/src/data/field-autonumber-default-format.test.ts b/packages/spec/src/data/field-autonumber-default-format.test.ts index ee6e4c4a50..ac80bf3fb8 100644 --- a/packages/spec/src/data/field-autonumber-default-format.test.ts +++ b/packages/spec/src/data/field-autonumber-default-format.test.ts @@ -57,7 +57,12 @@ describe('FieldSchema.autonumberFormat — the declared contract default (#6555) // metadata loaders, the metadata API and the drivers' `initObjects`, so // that shift would be visible far outside autonumber. for (const type of ['text', 'number', 'lookup', 'boolean'] as const) { - const parsed = FieldSchema.parse({ type, label: 'X' }) as Record; + // #13632: `lookup` requires a non-empty `reference` at parse — the type + // stays in this sample set for its key-absence behavior, in legal shape. + const fixture = type === 'lookup' + ? { type, label: 'X', reference: 'company' } + : { type, label: 'X' }; + const parsed = FieldSchema.parse(fixture) as Record; expect(parsed).not.toHaveProperty('autonumberFormat'); } const auto = FieldSchema.parse({ type: 'autonumber', label: 'No.' }) as Record; diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index eef97e683f..67681ef6d6 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -439,7 +439,13 @@ describe('FieldSchema', () => { it('absent maxLength stays absent — no default materializes, on any type', () => { for (const type of ['text', 'boolean', 'lookup'] as const) { - const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record; + // #13632: `lookup` requires a non-empty `reference` at parse — the + // type stays in this sample set for its key-absence behavior, in + // legal shape. + const fixture = type === 'lookup' + ? { name: 'f', label: 'F', type, reference: 'company' } + : { name: 'f', label: 'F', type }; + const result = FieldSchema.parse(fixture) as Record; expect('maxLength' in result).toBe(false); } }); @@ -532,7 +538,13 @@ describe('FieldSchema', () => { it('absent minLength stays absent — no default materializes, on any type', () => { for (const type of ['text', 'boolean', 'lookup'] as const) { - const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record; + // #13632: `lookup` requires a non-empty `reference` at parse — the + // type stays in this sample set for its key-absence behavior, in + // legal shape. + const fixture = type === 'lookup' + ? { name: 'f', label: 'F', type, reference: 'company' } + : { name: 'f', label: 'F', type }; + const result = FieldSchema.parse(fixture) as Record; expect('minLength' in result).toBe(false); } }); @@ -2125,3 +2137,71 @@ describe('Polymorphic pointer pair — referenceVia (#11339, ADR-0052 §5)', () expect(prop!.description).toMatch(/referential integrity/); }); }); + +describe('Relationship target — `reference` required on lookup/master_detail (ADR-0049 declared = enforced)', () => { + // The key's own TSDoc has always called `reference` required on these two + // types; the schema now enforces it. A missing key and the empty string are + // the SAME hole (both were measured as accepted before the check landed), + // so each gets its own pin per type. + + it.each(['lookup', 'master_detail'] as const)( + 'refuses a %s with no reference, prescribing the key on the `reference` path', + (type) => { + const result = FieldSchema.safeParse({ name: 'company_id', label: 'Company', type }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'reference'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain(`\`${type}\``); + expect(issue!.message).toMatch(/non-empty `reference`/); + expect(issue!.message).toMatch(/target object/); + }, + ); + + it.each(['lookup', 'master_detail'] as const)( + 'refuses a %s with an empty-string reference — a spelled-out missing target', + (type) => { + const result = FieldSchema.safeParse({ + name: 'company_id', + label: 'Company', + type, + reference: '', + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'reference'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/non-empty `reference`/); + }, + ); + + it.each(['lookup', 'master_detail'] as const)( + 'accepts a %s with a non-empty reference (positive control: the check refuses only the hole)', + (type) => { + const result = FieldSchema.safeParse({ + name: 'company_id', + label: 'Company', + type, + reference: 'company', + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.reference).toBe('company'); + }, + ); + + it('leaves non-relationship types alone — a bare text field parses with no reference', () => { + const result = FieldSchema.safeParse({ name: 'title', label: 'Title', type: 'text' }); + expect(result.success).toBe(true); + }); + + it('helper builders emit the target as `reference`, so helper-authored fields pass', () => { + const viaLookup = FieldSchema.safeParse({ + name: 'account', + ...Field.lookup('crm_account', { label: 'Account' }), + }); + expect(viaLookup.success).toBe(true); + const viaMasterDetail = FieldSchema.safeParse({ + name: 'order', + ...Field.masterDetail('crm_order', { label: 'Order' }), + }); + expect(viaMasterDetail.success).toBe(true); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 57ff40b5e5..d4e3c6b6df 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1063,7 +1063,9 @@ export const FieldSchema = lazySchema(() => { * * Used by `lookup` and `master_detail` field types to define cross-object references. * The `reference` property is **required** for these types — it identifies the target - * object whose records this field links to. The engine uses `reference` during $expand + * object whose records this field links to, and the superRefine below enforces it: + * a `lookup` / `master_detail` whose `reference` is missing or empty is refused at + * parse time. The engine uses `reference` during $expand * post-processing to resolve foreign key IDs into full related objects via batch queries. * * For `master_detail` fields, the parent record controls the lifecycle of child records @@ -1689,6 +1691,36 @@ export const FieldSchema = lazySchema(() => { }); } + // [#13632] (ADR-0049 declared = enforced): the `reference` TSDoc above has + // always called the key REQUIRED on the relationship types, but the schema + // accepted a `lookup` / `master_detail` with the key missing or `''` — a + // relationship that points nowhere. Downstream nothing can act on it (the + // record picker has no object to query, `$expand` nothing to resolve, and + // since #13222 driver-mongodb silently skips the lookup index), and lint's + // `relationship/missing-reference` already calls the same hole an error — + // the publish seam was the one door left open, exactly where AI-authored + // metadata that omits the key would otherwise parse cleanly and fail far + // from the cause. `reference` has no schema default, so `undefined` here + // always means "not authored"; `''` is the same hole spelled out (both + // measured as accepted before this check). `Field.lookup()` / + // `Field.masterDetail()` take the target as their first positional + // argument, so helper-authored fields cannot miss it. + if ( + (field.type === 'lookup' || field.type === 'master_detail') && + (field.reference === undefined || field.reference === '') + ) { + ctx.addIssue({ + code: 'custom', + path: ['reference'], + message: + `A \`${field.type}\` field requires a non-empty \`reference\` naming the target object its ` + + "records link to (snake_case, e.g. `reference: 'account'`). Without a target the relationship " + + 'is not actionable: the record picker has no object to query, `$expand` has nothing to ' + + 'resolve, and no relationship index can be built. Declare `reference`, or use a ' + + 'non-relationship type if this field does not link records.', + }); + } + // ADR-0113: `storage.notNull` × `requiredWhen` is a contradiction, rejected // at the authoring seam — when the condition is FALSE the write contract // permits null, but the column would refuse it, so the author has declared From e7407339c968d096bf4e6f945117036dae250fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:44:41 +0000 Subject: [PATCH 2/2] docs(spec): adr-0087 disposition marker; re-anchor system-context census line The changeset gains the not-required (no-migration-prescription) marker mirroring the #11519/#11842/#13733 narrowing precedents; the superRefine insertion shifted field.zod.ts line numbers, so the system-context page anchor is re-anchored by the census --fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .changeset/lookup-master-detail-reference-required.md | 2 ++ content/docs/permissions/system-context.mdx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/lookup-master-detail-reference-required.md b/.changeset/lookup-master-detail-reference-required.md index 20004b9ca6..5eb3fd9d5f 100644 --- a/.changeset/lookup-master-detail-reference-required.md +++ b/.changeset/lookup-master-detail-reference-required.md @@ -37,3 +37,5 @@ and docs samples all declare targets; the census and its positive controls are recorded on the PR). No key is removed or renamed, so there is no ADR-0087 registry entry — the key stays authorable with the same meaning; only the missing/empty hole closes. + + diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b23f88f9e3..ecee4f544f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9588`–`9605` | -| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) | +| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |