diff --git a/.changeset/9139-cbp-master-detail-required-error.md b/.changeset/9139-cbp-master-detail-required-error.md new file mode 100644 index 00000000000..a47929108e0 --- /dev/null +++ b/.changeset/9139-cbp-master-detail-required-error.md @@ -0,0 +1,33 @@ +--- +"@objectstack/lint": minor +"@objectstack/spec": minor +--- + +feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) + +Clause-②: no (narrowing) + +A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: + +1. `required` absent, or `required: false`; +2. `required: true` with `readonly: true`; +3. `required: true` with `system: true`. + +A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. + +**BREAKING — what moves for consumers.** + +- `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. +- `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. + +**Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. + +**Unchanged.** + +- On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. +- The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. +- Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. +- No export or signature moves in either package. +- Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. + + diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 19d80c88255..a9b86a3d45d 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -515,11 +515,12 @@ measured to create a detail record whose master reference is null — a record t readable by nobody and answers 422 on every later write by id. The refusal is correct; only its status departs from the rule above. -**These shapes are authorable today.** Publish-time lint reports a `master_detail` without -`required` as a *warning* (`relationship/master-detail-required`), and does not report the -`readonly`, `system`, or fallback-`lookup` shapes at all — so a stack can publish clean and -still reach the 422. Branch on `code`, and read the status off the response rather than -deriving it from this page. +**These shapes are authorable today.** `os lint` refuses the three `master_detail` shapes at +*error* (`relationship/master-detail-required`), but that rule is not part of the publish +gate — `os build`, `os validate` and the metadata save door do not run it — and it does not +report the fallback-`lookup` shape at all. So a stack can publish clean and still reach the +422. Branch on `code`, and read the status off the response rather than deriving it from this +page. #### `INVALID_FIELD` **HTTP Status:** 400 diff --git a/packages/cli/test/score.test.ts b/packages/cli/test/score.test.ts index 89e5ee3154c..b08927ce7d9 100644 --- a/packages/cli/test/score.test.ts +++ b/packages/cli/test/score.test.ts @@ -87,15 +87,21 @@ describe('scoreMetadata', () => { { name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, inlineEdit: true } } }, ], }); - // A warning: master_detail not required. + // A warning: master_detail not required — on an object that is NOT + // `controlled_by_parent`. Under `controlled_by_parent` the same shape is an + // error (`relationship/master-detail-required`'s v18 tier), so a fixture + // kept there would still pass the comparison below while measuring the + // error weight, not the warning one. const withWarning = scoreMetadata({ objects: [ { name: 'invoice', label: 'Invoice', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, - { name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } }, + { name: 'invoice_line', label: 'Line', sharingModel: 'private', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } }, ], }); expect(onlySuggestions.score).toBeGreaterThan(withWarning.score); expect(onlySuggestions.counts.errors).toBe(0); + expect(withWarning.counts.errors).toBe(0); + expect(withWarning.counts.warnings).toBeGreaterThan(0); }); it('reports the schema error messages', () => { diff --git a/packages/lint/src/data-model-rules.master-detail-required.test.ts b/packages/lint/src/data-model-rules.master-detail-required.test.ts new file mode 100644 index 00000000000..3488b722465 --- /dev/null +++ b/packages/lint/src/data-model-rules.master-detail-required.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * R2 `relationship/master-detail-required` — two tiers under one rule id. + * + * On a `sharingModel: 'controlled_by_parent'` object the master reference is + * what the object's record access is derived through, and three declarable + * shapes leave the security gate as the only thing refusing a detail record + * saved without its master: `required` absent (or `false`), `required: true` + + * `readonly: true`, and `required: true` + `system: true` (record validation + * never checks a non-required field and skips readonly/system fields before its + * required check). All three are refused here at `error` — the v18 narrowing of + * the authoring contract (maintainer ruling of 2026-08-16, Direction 1). + * + * Before the narrowing the predicate was `required !== true` at `warning` on + * every object, so the two flagged shapes drew no finding at ANY severity. The + * cases below pin each shape separately for that reason: a one-line severity + * flip would have turned the first red and left the other two silent. + * + * The other half is what keeps this from being a blanket escalation: outside + * `controlled_by_parent` nothing at runtime refuses a non-required + * `master_detail`, so there the verdict is unchanged — a `warning` on a missing + * `required`, and silence on the two flagged shapes. + */ + +import { describe, expect, it } from 'vitest'; +import { Field, ObjectSchema } from '@objectstack/spec/data'; +import { lintDataModel } from './data-model-rules.js'; + +const R2 = 'relationship/master-detail-required'; + +/** The master, plus one detail object carrying exactly the fields under test. */ +const stack = (sharingModel: string | undefined, fields: unknown): unknown[] => [ + { name: 'work_order', sharingModel: 'private', fields: { name: { type: 'text' } } }, + { name: 'work_order_item', ...(sharingModel ? { sharingModel } : {}), fields }, +]; + +const r2 = (objects: unknown[]) => lintDataModel(objects as any[]).filter((issue) => issue.rule === R2); + +/** A `master_detail` reference to `work_order`, plus the shape under test. */ +const masterRef = (shape: Record) => ({ + order: { type: 'master_detail', reference: 'work_order', deleteBehavior: 'cascade', ...shape }, +}); + +const UNSAFE_SHAPES: Array<[label: string, shape: Record, path: string]> = [ + ['`required` absent', {}, 'objects[1].fields.order.required'], + ['`required: false`', { required: false }, 'objects[1].fields.order.required'], + ['`required: true` + `readonly: true`', { required: true, readonly: true }, 'objects[1].fields.order.readonly'], + ['`required: true` + `system: true`', { required: true, system: true }, 'objects[1].fields.order.system'], +]; + +describe('R2 on a controlled_by_parent object — the three unsafe shapes are refused at error', () => { + it.each(UNSAFE_SHAPES)('%s → one error, located at the defect', (_label, shape, path) => { + const issues = r2(stack('controlled_by_parent', masterRef(shape))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: 'error', rule: R2, path }); + }); + + // ── CONTROLS — the refusal must be able to stay silent, or the reds above + // prove nothing about the shapes they name. + it('CONTROL: `required: true` with neither flag is clean', () => { + expect(r2(stack('controlled_by_parent', masterRef({ required: true })))).toEqual([]); + }); + + it('CONTROL: an explicit `readonly: false` / `system: false` is clean — the flag, not the key, is the defect', () => { + expect( + r2(stack('controlled_by_parent', masterRef({ required: true, readonly: false, system: false }))), + ).toEqual([]); + }); + + it('a field carrying several defects is ONE finding whose fix names every edit', () => { + const issues = r2(stack('controlled_by_parent', masterRef({ readonly: true, system: true }))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.required' }); + expect(issues[0].fix).toContain('required: true'); + expect(issues[0].fix).toContain('readonly'); + expect(issues[0].fix).toContain('system'); + }); + + // Scope is every `master_detail` of the object, as the builder half of the + // same ruling enforces it — not only the one the runtime resolves as master. + it('reports every master_detail field of the object, each on its own path', () => { + const issues = r2( + stack('controlled_by_parent', { + order: { type: 'master_detail', reference: 'work_order', required: true }, + batch: { type: 'master_detail', reference: 'work_order', required: true, readonly: true }, + legacy: { type: 'master_detail', reference: 'work_order' }, + }), + ); + expect(issues.map((issue) => [issue.severity, issue.path])).toEqual([ + ['error', 'objects[1].fields.batch.readonly'], + ['error', 'objects[1].fields.legacy.required'], + ]); + }); + + it('reads the array field form too', () => { + const issues = r2( + stack('controlled_by_parent', [{ name: 'order', type: 'master_detail', reference: 'work_order', required: true, system: true }]), + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.system' }); + }); + + it('a lookup is not R2’s subject, whatever the sharing model', () => { + expect( + r2(stack('controlled_by_parent', { order: { type: 'lookup', reference: 'work_order', readonly: true } })), + ).toEqual([]); + }); +}); + +describe('R2 outside controlled_by_parent — the verdict is unchanged', () => { + it.each([['private'], ['public_read'], ['public_read_write'], [undefined]])( + 'sharingModel %s: a missing `required` is still a warning, at `.required`', + (sharingModel) => { + const issues = r2(stack(sharingModel, masterRef({}))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'warning', + path: 'objects[1].fields.order.required', + fix: 'required: true', + }); + }, + ); + + it.each([ + ['`required: true` + `readonly: true`', { required: true, readonly: true }], + ['`required: true` + `system: true`', { required: true, system: true }], + ])('sharingModel private: %s draws nothing, as before', (_label, shape) => { + expect(r2(stack('private', masterRef(shape)))).toEqual([]); + }); +}); + +// What actually reaches the rule from the authoring builder. Under +// `controlled_by_parent`, `ObjectSchema.create()` forces an omitted `required` +// to `true` and refuses an explicit `false`, but never inspects `readonly` or +// `system` — so the two flagged shapes leave the builder intact, and this rule +// is the authoring-time refusal they meet. +describe('R2 over objects built by ObjectSchema.create()', () => { + const build = (field: ReturnType) => + ObjectSchema.create({ + name: 'work_order_item', + sharingModel: 'controlled_by_parent', + fields: { order: field }, + }); + + it.each([ + ['readonly', { readonly: true }], + ['system', { system: true }], + ])('a %s master reference passes the builder (forced required) and is refused here', (flag, extra) => { + const detail = build({ ...Field.masterDetail('work_order', { label: 'Work Order' }), ...extra }); + const issues = r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail]); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: 'error', path: `objects[1].fields.order.${flag}` }); + }); + + it('CONTROL: the builder’s forced `required: true` on a plain master reference lints clean', () => { + const detail = build(Field.masterDetail('work_order', { label: 'Work Order' })); + expect(r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail])).toEqual([]); + }); +}); diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index 04cde0f6295..d1494490b69 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -250,6 +250,108 @@ function refOf(def: any): string | undefined { return referenceCarrierOf({ reference: def?.reference }, 'data-model-rules refOf'); } +// ─── R2 under controlled_by_parent ────────────────────────────────── + +/** The rule id R2 reports under, on both tiers. */ +const MASTER_DETAIL_REQUIRED = 'relationship/master-detail-required'; + +/** + * R2's `error` tier — a `master_detail` reference on a + * `sharingModel: 'controlled_by_parent'` object in one of the three shapes that + * leave the security gate as the only thing refusing a detail record saved + * without its master: + * + * 1. `required` absent (or `false`); + * 2. `required: true` + `readonly: true`; + * 3. `required: true` + `system: true`. + * + * Why these three. A `controlled_by_parent` detail derives ALL of its record + * access from the master its `master_detail` reference names (ADR-0055: such an + * object "must declare exactly one required `master_detail` field"). Record + * validation (`validateRecord`, `@objectstack/objectql`) never checks a field + * that is not `required`, and skips `readonly` / `system` fields before its + * required check is reached — so on all three shapes an insert that omits the + * master FK is refused by `assertControlledByParentWrite` (plugin-security) and + * by nothing else. A record that lands without its master anyway is readable by + * nobody: the derived read filter `masterFK IN (accessible master ids)` never + * matches null, and every later by-id write is refused. + * + * Why `error` here and `warning` elsewhere. This is the criterion + * `validate-security-posture.ts` states at its head — an `error` rule mirrors a + * hard runtime enforcement point and moves the failure from runtime-deny to an + * author-time fix. Under `controlled_by_parent` the security gate's refusal + * stands behind every one of these shapes; outside it nothing at runtime refuses + * a non-required `master_detail`, so there it stays a likely-wrong choice at + * `warning`, unchanged. Maintainer ruling of 2026-08-16, Direction 1, scheduled + * for the v18 boundary — the card that carries it verbatim is #9139 (the thread + * the ruling was recorded on no longer resolves on the board); the runtime half + * of that ruling is untouched + * (the security gate's fallbacks stay, so metadata already at rest keeps + * loading and is still guarded). + * + * Scope — EVERY `master_detail` field of a `controlled_by_parent` object, not + * only the one the runtime resolves as the master: that is the scope the + * builder half of the same ruling already enforces (`ObjectSchema.create()` + * forces `required: true` on every `master_detail` reference under + * `controlled_by_parent` and refuses an explicit `required: false` — + * `forceCbpMasterDetailRequired` in `packages/spec/src/data/object.zod.ts`). + * The builder never inspects `readonly` / `system`, so shapes 2 and 3 pass + * through `create()` untouched, and shape 1 survives every path that does not + * go through `create()` (a plain object literal, raw `.parse()` of stored + * metadata); this rule is where all three meet a refusal at authoring time. + * + * ⚠ Reach: `lintDataModel` is `os lint`'s sweep and the metadata-generation + * rubric (`score.ts`), and nothing else — it is not an `AUTHORING_RULES` entry + * (`authoring-rule-wiring.test.ts` → `DIRECT_CALL_RATCHET`), so `os build`, + * `os validate` and the metadata save door do not run it. The `error` moves + * `os lint`'s exit code and the rubric's `valid`, not a publish verdict. + * + * One finding per field, located at the first defect it names; the `fix` + * names every edit the field needs. + * + * ⛔ Keep this above the first `export function`: `authoring-rule-wiring.test.ts` + * reads an exported rule's body as the source text up to the next `export`, so + * an `error`-emitting helper placed below one of the three registered advisory + * rules above `lintDataModel` is read as that rule emitting `error`. + */ +function cbpMasterReferenceFinding( + objectName: string, + fieldName: string, + fieldPath: string, + parent: string, + def: any, +): LintIssue | undefined { + const missingRequired = def.required !== true; + const flags = (['readonly', 'system'] as const).filter((flag) => def[flag] === true); + if (!missingRequired && flags.length === 0) return undefined; + + const subject = `master_detail "${objectName}.${fieldName}" → ${parent}`; + const derivation = + `"${objectName}" is controlled_by_parent, so its record access is derived through this ` + + `reference — a record saved without its master is readable by nobody (the derived read filter ` + + `never matches an empty master) and refused on every later write`; + const flagWords = flags.map((flag) => `\`${flag}: true\``).join(' and '); + const message = missingRequired + ? flags.length === 0 + ? `${subject} must be required: ${derivation}` + : `${subject} must be required and must not be ${flagWords}: ${derivation}; record validation ` + + `also skips readonly and system fields before its required check` + : `${subject} is required but also ${flagWords}: record validation skips readonly and system ` + + `fields before its required check, so \`required: true\` is never enforced on it — and ` + + `${derivation}`; + + return { + severity: 'error', + rule: MASTER_DETAIL_REQUIRED, + message, + path: `${fieldPath}.${missingRequired ? 'required' : flags[0]}`, + fix: [ + ...(missingRequired ? ['required: true'] : []), + ...flags.map((flag) => `drop ${flag}: true`), + ].join(', '), + }; +} + // ─── Uniqueness declarations (ADR-0120) ───────────────────────────── export const UNIQUE_DOUBLE_DECLARATION = 'unique/double-declaration'; @@ -736,10 +838,20 @@ export function lintDataModel(objects: any[]): LintIssue[] { if (type === 'master_detail') { // R2 — master-detail children should require their parent. - if (def.required !== true) { + // + // Two tiers, one rule id. On a `controlled_by_parent` object the master + // reference is what the object's whole record access is derived through, + // so an unsafe shape there is refused at `error` + // (`cbpMasterReferenceFinding`); everywhere else a non-required + // `master_detail` is a likely-wrong choice and stays a `warning`, + // byte-for-byte as before. + if (obj.sharingModel === 'controlled_by_parent') { + const finding = cbpMasterReferenceFinding(obj.name, fieldName, fieldPath, parent, def); + if (finding) issues.push(finding); + } else if (def.required !== true) { issues.push({ severity: 'warning', - rule: 'relationship/master-detail-required', + rule: MASTER_DETAIL_REQUIRED, message: `master_detail "${obj.name}.${fieldName}" → ${parent} should be required (a detail record cannot exist without its master)`, path: `${fieldPath}.required`, fix: 'required: true', diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 08aac8bdf37..b0c2889d702 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -31,6 +31,7 @@ import { SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION, } from './validate-security-posture.js'; +import { lintDataModel } from './data-model-rules.js'; const rulesOf = (stack: Record) => validateSecurityPosture(stack).map((f) => f.rule); @@ -814,10 +815,24 @@ describe('validateSecurityPosture · controlled_by_parent with no relation (#750 ).toEqual([]); }); - it('stays silent on step 2: ANY master_detail (not marked required)', () => { - expect( - cbpOnly(cbpStack({ order: { name: 'order', type: 'master_detail', reference: 'work_order' } })), - ).toEqual([]); + // Step 2 is a RUNTIME tolerance and nothing more. This pin used to read + // "ANY master_detail (not marked required)" as a supported shape; the v18 + // narrowing of the authoring contract (maintainer ruling of 2026-08-16, + // Direction 1) retires that reading. `resolveCbpRelation` keeps resolving a + // non-required master_detail — metadata already at rest keeps loading — so + // this rule, which mirrors the resolver, stays silent; and the SAME + // declaration is now refused at authoring time by + // `relationship/master-detail-required` at `error` (data-model-rules.ts). + // Both halves are asserted on one stack so neither can drift unseen. + it('stays silent on step 2: ANY master_detail (not marked required) resolves at runtime — and lint refuses that shape at error', () => { + const stack = cbpStack({ order: { name: 'order', type: 'master_detail', reference: 'work_order' } }); + expect(cbpOnly(stack)).toEqual([]); + + const authoring = lintDataModel(stack.objects as any[]).filter( + (issue) => issue.rule === 'relationship/master-detail-required', + ); + expect(authoring).toHaveLength(1); + expect(authoring[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.required' }); }); it('stays silent on step 3: a REQUIRED lookup', () => { diff --git a/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts b/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts new file mode 100644 index 00000000000..0149fa4ea64 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'cbp-master-detail-required-lint-error', + surface: 'object.fields.MASTER.required / .readonly / .system, where MASTER is a ' + + '`master_detail` reference on an object declaring `sharingModel: \'controlled_by_parent\'` ' + + '— as judged by `os lint` under `relationship/master-detail-required`', + replacement: '`required: true` on every `master_detail` reference of a `controlled_by_parent` ' + + 'object, with neither `readonly: true` nor `system: true` on it: declare the master ' + + 'reference as an ordinary required field. `os lint` now reports each of the three unsafe ' + + 'shapes there — `required` absent or `false`; `required: true` + `readonly: true`; ' + + '`required: true` + `system: true` — at `error` under `relationship/master-detail-required`, ' + + 'so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On ' + + 'every other object the rule is unchanged: a `warning` for a `master_detail` without ' + + '`required: true`, and no finding for the two flagged shapes.', + reason: + 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' + + '`master_detail` reference names (ADR-0055). Record validation never checks a field that is ' + + 'not `required`, and skips `readonly` and `system` fields before its required check is ' + + 'reached, so on these three shapes nothing but the security gate refuses an insert that ' + + 'omits the master FK. A record that lands without it anyway is readable by nobody — the ' + + 'derived read filter `masterFK IN (accessible master ids)` never matches null — and every ' + + 'later by-id write is refused. Before this step the lint predicate was `required !== true` ' + + 'at `warning` on every object: the two flagged shapes drew no finding at any severity, and ' + + 'the third drew a warning that an author or a generator could ignore. The maintainer ruling ' + + 'of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate ' + + 'narrowing of the authoring contract. Its builder half (the ' + + '`cbp-master-detail-required-forced` entry) forces `required: true` at ' + + '`ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes ' + + 'still pass the builder and meet their first authoring-time refusal here, and the third ' + + 'still reaches it from any object not authored through the builder. Runtime tolerance is ' + + 'unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving ' + + 'the master for metadata already at rest.', + acceptanceCriteria: + '`os lint` reports no `relationship/master-detail-required` finding at `error`: on every ' + + 'object declaring `sharingModel: \'controlled_by_parent\'`, each `master_detail` reference ' + + 'declares `required: true` (or omits it and is authored through `ObjectSchema.create`, ' + + 'which emits `required: true`) and carries neither `readonly: true` nor `system: true`. ' + + '⚠️ WHICH DOOR: the refusal is `os lint`\'s and the metadata-generation rubric\'s only. The ' + + 'rule is not in the authoring-rule registry, so `os build`, `os validate` and the metadata ' + + 'save door do not run it, and a stack carrying the shape still builds and publishes — read ' + + '`os lint`\'s exit code, not a green build. Stored metadata is not rewritten and keeps ' + + 'loading, and the security gate still refuses an insert that omits the master FK on these ' + + 'shapes. Repo census at the time of the change: 129 authored objects across the example ' + + 'apps, the platform and plugin objects and the CLI\'s golden eval corpus, 7 of them ' + + '`controlled_by_parent`, 0 findings at `error`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7fbbfba7c48..06066136db1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8272,6 +8272,51 @@ const step18: MigrationStep = { + 'reference under sharingModel: controlled_by_parent`. Stored metadata keeps loading ' + 'byte-identically (`safeParse` green, `required` unrewritten).', }, + { + id: 'cbp-master-detail-required-lint-error', + surface: 'object.fields.MASTER.required / .readonly / .system, where MASTER is a ' + + '`master_detail` reference on an object declaring `sharingModel: \'controlled_by_parent\'` ' + + '— as judged by `os lint` under `relationship/master-detail-required`', + replacement: '`required: true` on every `master_detail` reference of a `controlled_by_parent` ' + + 'object, with neither `readonly: true` nor `system: true` on it: declare the master ' + + 'reference as an ordinary required field. `os lint` now reports each of the three unsafe ' + + 'shapes there — `required` absent or `false`; `required: true` + `readonly: true`; ' + + '`required: true` + `system: true` — at `error` under `relationship/master-detail-required`, ' + + 'so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On ' + + 'every other object the rule is unchanged: a `warning` for a `master_detail` without ' + + '`required: true`, and no finding for the two flagged shapes.', + reason: + 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' + + '`master_detail` reference names (ADR-0055). Record validation never checks a field that is ' + + 'not `required`, and skips `readonly` and `system` fields before its required check is ' + + 'reached, so on these three shapes nothing but the security gate refuses an insert that ' + + 'omits the master FK. A record that lands without it anyway is readable by nobody — the ' + + 'derived read filter `masterFK IN (accessible master ids)` never matches null — and every ' + + 'later by-id write is refused. Before this step the lint predicate was `required !== true` ' + + 'at `warning` on every object: the two flagged shapes drew no finding at any severity, and ' + + 'the third drew a warning that an author or a generator could ignore. The maintainer ruling ' + + 'of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate ' + + 'narrowing of the authoring contract. Its builder half (the ' + + '`cbp-master-detail-required-forced` entry) forces `required: true` at ' + + '`ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes ' + + 'still pass the builder and meet their first authoring-time refusal here, and the third ' + + 'still reaches it from any object not authored through the builder. Runtime tolerance is ' + + 'unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving ' + + 'the master for metadata already at rest.', + acceptanceCriteria: + '`os lint` reports no `relationship/master-detail-required` finding at `error`: on every ' + + 'object declaring `sharingModel: \'controlled_by_parent\'`, each `master_detail` reference ' + + 'declares `required: true` (or omits it and is authored through `ObjectSchema.create`, ' + + 'which emits `required: true`) and carries neither `readonly: true` nor `system: true`. ' + + '⚠️ WHICH DOOR: the refusal is `os lint`\'s and the metadata-generation rubric\'s only. The ' + + 'rule is not in the authoring-rule registry, so `os build`, `os validate` and the metadata ' + + 'save door do not run it, and a stack carrying the shape still builds and publishes — read ' + + '`os lint`\'s exit code, not a green build. Stored metadata is not rewritten and keeps ' + + 'loading, and the security gate still refuses an insert that omits the master FK on these ' + + 'shapes. Repo census at the time of the change: 129 authored objects across the example ' + + 'apps, the platform and plugin objects and the CLI\'s golden eval corpus, 7 of them ' + + '`controlled_by_parent`, 0 findings at `error`.', + }, // The CEL-lowering face of the list-comparand refusal: the pushdown compiler // every row-level policy and declared sharing rule compiles through, plus the // driver-mongodb face that answered the lowered shape. Recorded as its own entry