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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(spec): require a non-empty reference on lookup/master_detail fields by os-warren · Pull Request #13927 · objectstack-ai/objectstack · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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