Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/lookup-master-detail-reference-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@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.

<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over an existing key: `reference` is not removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The parse refusal is the channel that reaches an affected author, at the parse site, carrying the remedy; which target object a targetless `lookup` / `master_detail` was meant to point at is authoring intent no migration entry can decide on an upgrader's behalf — and the measured population of affected sources is zero in every in-tree corpus (census on the PR). Mirrors the disposition of the #11519 / #11842 ActionSchema narrowings and the #13733 wizard tightening. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
// #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<string, unknown>;
expect(parsed).not.toHaveProperty('autonumberFormat');
}
const auto = FieldSchema.parse({ type: 'autonumber', label: 'No.' }) as Record<string, unknown>;
Expand Down
84 changes: 82 additions & 2 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
// #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<string, unknown>;
expect('maxLength' in result).toBe(false);
}
});
Expand Down Expand Up @@ -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<string, unknown>;
// #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<string, unknown>;
expect('minLength' in result).toBe(false);
}
});
Expand Down Expand Up @@ -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);
});
});
34 changes: 33 additions & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading