Skip to content

Commit 76815b9

Browse files
feat(spec)!: a controlled_by_parent object derives its master from exactly one required master_detail; the runtime's fallback tiers retire (#23100) (#23179)
Fixes #23100 Clause-②: no (narrowing) ## What this does Ruling `6134079470` (batch #331 item 4, letter **B**) enforces ADR-0055 §1: a `controlled_by_parent` object derives its master from **exactly one `master_detail` field declared `required: true`**. Before this PR the parse, the linter and the runtime gave three different answers. They now apply that one rule, and each seam keeps its own read of it. | seam | before | after | |---|---|---| | **parse** — `ObjectSchema` (`packages/spec/src/data/object.zod.ts`) | accepted a `controlled_by_parent` object with no master, with a non-required `master_detail`, with a required `lookup`, or with two masters | `refuseControlledByParentWithoutOneMaster` in `ObjectSchemaBase`'s superRefine refuses all four shapes. It names the object and ADR-0055 §1. Zero masters are reported at `sharingModel`, and the message names each field that relied on a retired tier. Two or more are reported at `fields`. It applies to raw `.parse()`/`.safeParse()`, `create()`, `defineStack`, `os validate` and the save door. | | **lint** — `packages/lint/src/validate-security-posture.ts` | `CBP_TIERS`, a three-tier table "mirroring the runtime's winner, exactly" | One predicate, `cbpMasterCandidates`. The no-relation rule fires on zero candidates. The ambiguity rule fires on two or more, and its verdict is now "no master is derived": it no longer names a winner by field order. The `os explain` entries are rewritten to match. | | **runtime** — `resolveCbpRelation` (`packages/plugins/plugin-security/src/security-plugin.ts`) | `pick(required master_detail) ?? pick(any master_detail) ?? pick(required lookup)`, first by field order | Exactly one required `master_detail` that has a reference resolves. Anything else resolves `null`, which is fail-closed: reads get `RLS_DENY_FILTER`, writes get `422 INVALID_METADATA`. A new `reportCbpMasterUnresolved` warns once per object per cache generation and names the shape the object relied on. The reference-**spelling** tolerance in the same reader is a separate, deliberate decision and is unchanged. | ### ADR-0087 disposition: one lossless shape converted, the rest refused loudly - **Disposition.** It is `registered cbp-master-detail-required-defaulted, cbp-master-relation-tiers-retired`: a new step-18 D2 conversion and a new step-18 D3 semantic entry. The D2 covers exactly one shape. A `controlled_by_parent` object whose one `master_detail` omits `required` gets `required: true`, the force `ObjectSchema.create()` already applies. objectui's Studio writes that shape: its `newField` emits `{ type, label }`, its Required toggle writes `required: v || undefined`, and its Settings tab offers `controlled_by_parent`. The conversion is retired from the authoring load path, so a live author is still refused at parse. - Not converted: a required `lookup`. Turning it into a `master_detail` changes what deleting the master does to every row, and leaving `controlled_by_parent` is the author's call, per the ruling's fork clause. - Not converted: an explicit `required: false` (the builder refuses it too) and two masters (choosing one is the pick §1 refuses). - **Stored rows.** `applyConversionsToStoredItem` replays `cbp-master-detail-required-defaulted`. A stored row whose one `master_detail` omits `required` is therefore served with `required: true` and resolves the same master it did before. A stored row in any other retired shape fails `ObjectSchema` and is still registered under its `[metadata_spec_invalid]` diagnostic, which carries the parse's message; the runtime then denies the object and reports it once. No tier is left silently live anywhere. - **Sibling entries.** The two step-18 entries on the same surface (`cbp-master-detail-required-forced`, `cbp-master-detail-required-lint-error`) are amended in place. Their "raw parse stays tolerant" and "the runtime keeps resolving the master" sentences would otherwise contradict this step inside the same unreleased major. ### HotCRM: measured, not edited These are read-only readings of the clone at `0f84c9c`. No hotcrm card is filed here, per the ruling. | object | relation today | refused by this rule | |---|---|---| | `crm_campaign_member` | `crm_campaign: Field.lookup(..., { required: true })`, no `master_detail` | **yes** (tier 3) | | `crm_event_attendee` | `crm_event: Field.lookup(..., { required: true })`, no `master_detail` | **yes** (tier 3) | | `crm_article_feedback` | `crm_knowledge_article: Field.lookup(..., { required: true, deleteBehavior: 'cascade' })`, no `master_detail` | **yes** (tier 3) | | `crm_contract`, `crm_quote`, `crm_quote_line_item`, `crm_opportunity_line_item`, `crm_contact` | one `Field.masterDetail(..., { required: true })` each | no | The changeset's FROM → TO names the three objects. For `crm_campaign_member` and `crm_event_attendee` it prescribes `deleteBehavior: 'restrict'`. Both objects document today's behaviour on purpose: a campaign or event that still has members or attendees cannot be deleted. A plain `master_detail` would cascade instead. `crm_article_feedback` already cascades. ### Fixture triage (the three dispositions) | fixture | disposition | |---|---| | `spec` `object.test.ts`: "forces EVERY master_detail reference" | **replaced**. It pinned that nothing enforced the count, and now pins the two-master refusal. | | `spec` `object.test.ts`: "raw .parse() stays TOLERANT" | **replaced**. It is now the refusal, with the stored body still unrewritten. | | `lint` posture tests: step-2 and step-3 silent controls, tier-2/3 ties, "names the OTHER field as the winner", the #22212 lookup-child warning | **replaced**. Each is reversed into the retired-tier refusal. | | `lint` `validate-expressions.test.ts`: master-detail count fixture | **re-spelled**. It used `controlled_by_parent` incidentally and now uses `private`. | | `verify` `parent-write-reach.test.ts`: every `controlled_by_parent` fixture's `master_detail` | **declaration added** (`required: true`). These fixtures were never §1-legal: through `defineStack` they ran on tier 2. `pwr_two_masters` and `pwr_no_master` are now excluded by the parse's own refusal. | | `plugin-security` `controlled-by-parent-sharing.test.ts`: the tier-2 row of the 422 `MISSING_REQUIRED_FIELD` `it.each`, and the required-lookup case | **replaced**. Both move into a new retired-tier block (below). | | `objectql` `engine-unreadable-master-detail-header.test.ts`: `uh_cbp_line`'s `master_detail` | **declaration added** (`required: true`, on that object only). objectql registers it raw, so the suite stayed green either way, but the shape is no longer §1-legal. | ### In-flight coupling - **#23114 (#23093).** It landed on `main` while this was in flight and has been merged in. Its fixtures in `controlled-by-parent-chain-middle-master.test.ts` are tier 1. Its hunks in `packages/verify/src/parent-write-reach.test.ts` (around `:240`–`:285`) and this PR's (around `:60`–`:180`, plus the controls fixture near `:309`) merged without conflict. Before this PR, the verify fixtures it relies on (`pwr_quote`, `pwr_quote_line`) resolved through tier 2. - **#22900.** It landed too. Its boot and registration regions are not touched here. ## Pins (one refusal and one accepted control per seam) - **Parse** (`packages/spec/src/data/object.test.ts`): the accepted control covers raw parse, builder and lookups beside the master. The refusals cover the required lookup (parse and builder), the non-required `master_detail` (omitted and `false`), no relation, two masters, and other sharing models left alone. `defineStack` is covered both ways. - **Lint** (`packages/lint/src/validate-security-posture.test.ts`): each rule's control and its refusals. A new `it.each` holds the lint's verdict equal to the parse's on five shapes. The accepted shape is asserted to parse whole, so the refusals come from this rule rather than from a refinement that never ran. - **Runtime** (`packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts`, block "the retired master tiers derive NO master"): the accepted control reads rows, reaches the executor and reports nothing. Each retired shape (non-required `master_detail`, required `lookup`, two masters) is checked for all of the following: - reads denied on both the middleware face and the analytics face; - the by-id update and the insert answer `422 INVALID_METADATA`; - the report fires exactly once; - the parse refuses the same schema at the same path. ## Verification Readings are at three trees. **`ba6686ce6`** is the final head: `main` at `02bec6ca8` merged in. **`7ed362fd2`** is the same diff before that merge; the only difference is 7 `main` commits that touch none of this PR's files. **`b17a2e120`** is the first merge of `main`, before one verify-test commit. Every exit code was captured before any pipe. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ba6686ce6` derives 127 commands, and all 127 were run there. - 126 exited 0. - 1 exited 2: `node scripts/check-engine-split-ratio.mjs --days 90` refused because this clone is shallow (its oldest commit, 2026-09-12, is inside the 90-day window). **NOT MEASURED** (shallow clone). CI runs it on a full checkout. - `--ran` reconcile line: "✓ dispatch-gates --ran: 127 derived famil(ies) accounted for — 127 run, 0 NOT-MEASURED". Verdict lines quoted from that run: - `check-adr-0087-registration`: "1 declared-breaking changeset(s) … [BREAKING+bang+clause-②-narrowing] registered cbp-master-relation-tiers-retired (new here: cbp-master-relation-tiers-retired)" - `check:api-surface`: "@objectstack/spec public API surface + factory signatures unchanged ✓" - `check:liveness`: "packages/spec/liveness/state-counts/ is current" - `check:type-check-debt`: "1 ledger entr(ies) re-measured … none above its recorded number" - `check:error-status-conformance`: "every derivable runtime status is documented, and every documented status is reachable" - `check:nul-bytes`: "no raw ASCII control bytes" - `check:doc-authoring`: "no growth" - `check:adr-anchors`: OK - `check:changeset-no-major` and `check:empty-changeset`: green **Suites.** | package | tree | files / tests | |---|---|---| | `@objectstack/spec` | `b17a2e120` | 660 / 20027 pass (1 todo) | | `@objectstack/spec` targeted (`object.test.ts`, migrations, error-catalog docs, step-18 merge) | `ba6686ce6` | 7 / 468 | | `@objectstack/lint` | `b17a2e120` | 138 / 6654 | | `@objectstack/lint` targeted (posture, expressions, R2, runtime surface) | `ba6686ce6` | 4 / 619 | | `@objectstack/plugin-security` | `b17a2e120` | 192 / 3984 (45 skipped) | | `@objectstack/plugin-security` CBP files | `ba6686ce6` | 9 / 221 | | `@objectstack/verify` | `7ed362fd2` | 36 / 289 | | `@objectstack/verify` `parent-write-reach.test.ts` | `ba6686ce6` | 1 / 10 | | `@objectstack/objectql` | `b17a2e120` | 409 / 7998 | | `@objectstack/platform-objects` | `b17a2e120` | 70 / 1096 | | `@objectstack/example-crm` | `b17a2e120` | 6 / 46 | | `@objectstack/example-showcase` | `b17a2e120` | 33 / 409 | `typecheck` exited 0 for `spec`, `lint` and `plugin-security` at `b17a2e120`, and for `verify` at `ba6686ce6`. Each printed its `check:test-typecheck: OK` line. Every suite ran on a rebuilt dependency closure (`turbo run build --filter=…^...`). The sweep direction is downstream: the consumers of `@objectstack/spec` that hold `controlled_by_parent` fixtures or seeded objects. `turbo ls --affected` was not used to narrow anything here. **Census.** At `b17a2e120`, all 115 authored `*.object.ts` files outside tests and fixtures were imported through the built spec, so `ObjectSchema.create()` ran the new rule at module load. There were 0 import failures. Of 120 objects, 3 are `controlled_by_parent`: `crm_opportunity_line_item`, `showcase_expense_line` and `showcase_invoice_line`. Each has one required `master_detail` and parses. The CLI eval corpus adds 4 more `controlled_by_parent` objects, and the rule refuses none of them. A tier-3 control object was refused, which proves the census can see a refusal. **Ablation (reverse verification).** The fix was committed first. Each mutation went through `scripts/ablation-replace.mjs` in WRAP mode, which checks the anchor hit and the blob change on disk and restores with `git checkout HEAD`. Both subjects import from `src/` (relative imports), so no `dist/` leg applies. - **Parse.** Deleting the `refuseControlledByParentWithoutOneMaster(...)` call (anchor 1 → 0, call sites on disk 0) failed **6 / 205** in `object.test.ts`: every refusal pin. The accepted control and the "other sharing models" pin stayed green, as predicted. - **Runtime.** Putting the three-tier ladder back into `resolveCbpRelation` (marker on disk 1, anchor 0) failed **3 / 36** in `controlled-by-parent-sharing.test.ts`: the three retired-tier cases. The control and the no-master-detail 422 stayed green, as predicted. - **Restores.** Both were proven: the blob equals HEAD (`1f74c94d8fd6`, `ffac0c727d4f`) and `git diff HEAD` is empty. **ESLint (a declared narrowing).** `eslint --no-inline-config --format json` over the 15 touched `.ts` files reported 15 files, 0 errors and 0 warnings. - `--print-config` resolves each of the 15 inside the config. - The config enables no type-aware linting (`parserOptions` is `{ ecmaVersion, sourceType }` with no `project`), so this diff cannot move a verdict on an untouched file. - The repo-wide `pnpm lint` is CI's run. ## Acceptance notes (not changed here) - **`packages/verify/src/parent-write-reach.ts` `masterLinkOf`.** Its zero-master reason ends "a master the runtime would derive through a required lookup is the runtime's own precedence". No input the parse accepts reaches that branch now, and the runtime no longer has that precedence. Its two-master branch also counts every `master_detail`, so one required plus one non-required is still excluded, although the parse and the runtime both derive the required one. Report-line text only. Carrier: none. - **The same family's `notDerivable` reason.** "the master … cannot be synthesized" is unreachable for a §1-legal detail: its required master reference blocks the detail first. The pinned case now names the detail's own block, which is the same master one hop earlier. Carrier: none. - **Stale freeze note in `security-plugin.ts`.** The note in `assertControlledByParentWrite` still says "Direction 1 (#9139) has NOT landed". That has been stale since `ace0a5385`, before this PR. Only its first-shape bullet is marked superseded here. - **`required` reading.** The runtime now reads `required` as `=== true` (it was truthy), matching the lint. A raw-registered object with a non-boolean `required` on its master now fails closed. - **Object extensions are not judged by this rule.** An `ObjectExtensionSchema` that adds a second required `master_detail` to a `controlled_by_parent` base object is not judged at parse. The runtime fails such a merged object closed and reports it. Not measured beyond reading the code. - **For whoever files the HotCRM card.** `crm_campaign_member` and `crm_event_attendee` document refusal on delete as the intended behaviour, so `deleteBehavior: 'restrict'` on the new `master_detail` keeps it. The ruling's hotcrm text reads "delete semantics become cascade"; that holds only without `restrict`. ## Patch round 1 (review record 6139386784) - `content/docs/permissions/authorization.mdx`'s security-linter bullet now states the one-master rule, both rule ids included. - The step-18 rationale fragment `cbp-master-relation-tiers-retired` is added. - The D2 conversion `cbp-master-detail-required-defaulted` is added and pinned (`packages/spec/src/conversions/cbp-master-detail-required-defaulted.test.ts`). - The objectql fixture is re-judged. - Verified at `6582c1f0d`: `pnpm --filter @objectstack/spec test:repo` 'Test Files 57 passed (57)', `spec test` 661 passed + 1 skipped, `lint test` 137, plugin-security CBP files 9, objectql file 1, `check:generated` up to date, gates 127 derived / 124 run / 3 NOT-MEASURED (prerequisite), 0 UNRUN. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75cada0 commit 76815b9

25 files changed

Lines changed: 1240 additions & 464 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100)
8+
9+
Clause-②: no (narrowing)
10+
11+
ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove).
12+
13+
**BREAKING — what moves for consumers.**
14+
15+
- **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix.
16+
- **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field".
17+
- **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail.
18+
19+
**FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change.
20+
21+
| object | FROM | TO |
22+
|---|---|---|
23+
| `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` |
24+
| `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` |
25+
| `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` |
26+
27+
`deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`.
28+
29+
**Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`.
30+
31+
**Unchanged.**
32+
33+
- An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures.
34+
- Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision.
35+
- No export, signature or error code is added or removed.
36+
37+
<!-- adr-0087: registered cbp-master-detail-required-defaulted, cbp-master-relation-tiers-retired -->

‎content/docs/api/error-catalog.mdx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,16 @@ record, so the gate authorizing writes to it resolves that master *before* the e
182182
and the executor is where required-field validation runs. Which of the two refuses first
183183
depends on how the master reference is declared. A `master_detail` that is `required` and
184184
neither `readonly` nor `system` reaches validation and answers the documented
185-
`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and
185+
`400 VALIDATION_FAILED` with `fields`. The other two declarable shapes never reach it and
186186
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:
187187

188-
- a `master_detail` with no `required`
189188
- a `master_detail` that is `required` + `readonly`
190189
- a `master_detail` that is `required` + `system`
191-
- a `required` `lookup`, when the object declares no `master_detail`
190+
191+
A `master_detail` with no `required`, and a `required` `lookup` on an object that declares no
192+
`master_detail`, are not master references at all: the master is exactly one `master_detail`
193+
declared `required: true` (ADR-0055 §1). `ObjectSchema` refuses such an object at parse, and
194+
one that reaches the runtime unparsed answers every write `422 INVALID_METADATA`.
192195

193196
The full matrix is on the protocol page:
194197
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
@@ -197,17 +200,16 @@ An update or delete **by id** whose *stored* master reference is null answers th
197200
`422`, whatever the declaration — the caller sent no such field, so nothing could be named
198201
in `fields` and no payload would fix it.
199202

200-
The refusal itself is correct and is not going to be relaxed: on those four shapes
203+
The refusal itself is correct and is not going to be relaxed: on those two shapes
201204
required-field validation does not fire (it skips `system` and `readonly` fields before its
202-
required check, and never fires on a field that is not `required`), so the gate is the only
205+
required check), so the gate is the only
203206
thing standing between the request and a detail record with a null master reference — which
204207
the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving
205208
a record readable by nobody.
206209

207-
These shapes are **authorable today**: lint reports a `master_detail` without `required` as
208-
a warning only, and does not report the `readonly`, `system`, or fallback-`lookup` shapes at
209-
all. **Branch on `code`, treat `fields` as optional, and read the status off the response**
210-
rather than deriving it from the table at the end of this page.
210+
These shapes are **authorable today**: `os lint` reports both at error, but the publish gate
211+
does not run that rule. **Branch on `code`, treat `fields` as optional, and read the status
212+
off the response** rather than deriving it from the table at the end of this page.
211213
</Callout>
212214

213215
### `INVALID_FORMAT`

‎content/docs/permissions/authorization.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
517517
wider than internal (`security-external-wider-than-internal`), `'*'`
518518
wildcards carrying View/Modify All outside the platform admin set,
519519
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
520-
"role" in security identifiers, a `controlled_by_parent` object with no
521-
relation the platform can derive access from (ADR-0055: no required
522-
`master_detail`, no `master_detail` at all, and no required `lookup` — so
523-
the runtime denies every read and refuses every write), and the ADR-0091
520+
"role" in security identifiers, a `controlled_by_parent` object without
521+
exactly one required `master_detail` to derive access from (ADR-0055 §1:
522+
`security-controlled-by-parent-no-relation` when it declares none — a
523+
`master_detail` without `required: true` and a required `lookup` derive
524+
nothing — and `security-controlled-by-parent-ambiguous-relation` when it
525+
declares two or more; either way the runtime denies every read and refuses
526+
every write), and the ADR-0091
524527
grant-lifecycle rules (a seed grant already expired at authoring time; a
525528
delegation row missing its mandatory `reason`) — every error rule mirrors
526529
a runtime gate.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -497,35 +497,40 @@ depends on how the reference is declared:
497497
| Master reference declared as | Refused by | Status | `code` | `fields` |
498498
|---|---|:---:|---|:---:|
499499
| `master_detail` + `required`, not `readonly`/`system` | required-field validation | 400 | `VALIDATION_FAILED` | yes |
500-
| `master_detail` with no `required` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
501500
| `master_detail` + `required` + `readonly` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
502501
| `master_detail` + `required` + `system` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
503-
| a `required` `lookup`, when the object declares no `master_detail` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
504502
505-
Only the first row is the documented 400 shape. On the other four, messages are prefixed
503+
Only the first row is the documented 400 shape. On the other two, messages are prefixed
506504
`[Security] Missing master reference:` and name the object and the field, but no `fields`
507505
array rides along.
508506
507+
Two declarations are not rows of this table, because they name **no master at all**. A
508+
`controlled_by_parent` object derives its master from exactly one `master_detail` field
509+
declared `required: true` (ADR-0055 §1), so an object whose only relation is a `master_detail`
510+
without `required`, or a `required` `lookup`, derives none — and neither does one with two
511+
required `master_detail` fields. `ObjectSchema` refuses such an object at parse, naming it;
512+
one that reaches the runtime unparsed has every read denied and every write refused with
513+
`422 INVALID_METADATA`, whatever the request carries.
514+
509515
The same `422 MISSING_REQUIRED_FIELD` also answers an update or delete **by id** whose
510516
*stored* master reference is null, whatever the declaration. That is a different path: the
511517
caller supplied no such field, so there is no request field to name in `fields` and no
512518
payload that would fix it.
513519
514520
**Why the gate refuses rather than handing over.** Required-field validation skips
515521
provenance-flagged fields before its required check is reached (`system` and `readonly`
516-
fields are skipped outright) and never fires on a field that is not `required` at all. On
517-
those four shapes the gate is the only thing refusing the write, and letting it through was
522+
fields are skipped outright). On those two shapes the gate is the only thing refusing the
523+
write, and letting it through was
518524
measured to create a detail record whose master reference is null — a record the
519525
`controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, so it is
520526
readable by nobody and answers 422 on every later write by id. The refusal is correct; only
521527
its status departs from the rule above.
522528
523-
**These shapes are authorable today.** `os lint` refuses the three `master_detail` shapes at
524-
*error* (`relationship/master-detail-required`), but that rule is not part of the publish
525-
gate — `os build`, `os validate` and the metadata save door do not run it — and it does not
526-
report the fallback-`lookup` shape at all. So a stack can publish clean and still reach the
527-
422. Branch on `code`, and read the status off the response rather than deriving it from this
528-
page.
529+
**These shapes are authorable today.** `os lint` refuses both at *error*
530+
(`relationship/master-detail-required`), but that rule is not part of the publish gate —
531+
`os build`, `os validate` and the metadata save door do not run it. So a stack can publish
532+
clean and still reach the 422. Branch on `code`, and read the status off the response rather
533+
than deriving it from this page.
529534
530535
#### `INVALID_FIELD`
531536
**HTTP Status:** 400

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -598,39 +598,38 @@ const DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
598598

599599
const SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION: RuleExplanation = {
600600
rule: 'security-controlled-by-parent-ambiguous-relation',
601-
covers: 'how the master relation is chosen',
601+
covers: 'why a controlled_by_parent object takes exactly one master',
602602
paragraphs: [
603603
'A `controlled_by_parent` object derives every row\'s record-level access from a master record. ' +
604-
'ADR-0055 resolves the master through three tiers, in order — a required master_detail, then any ' +
605-
'master_detail, then a required lookup, each of which must also name a `reference` target — and ' +
606-
'takes the FIRST match in the tier that wins.',
607-
'When two or more fields tie in the winning tier, which object this one derives its access from ' +
608-
'is decided by FIELD DECLARATION ORDER. Moving a field up or down the schema reads as a cosmetic ' +
609-
'edit in review, and it silently moves that security boundary to another object; the runtime ' +
610-
'reports nothing when it does, because it does not refuse, it picks. Author time is the only ' +
611-
'place this can surface, so the rule is an error. A tie in a lower tier is masked by a higher ' +
612-
'tier\'s single winner and is not reported.',
613-
'To fix it, leave exactly ONE candidate in the winning tier: promote the intended master into a ' +
614-
'higher tier (make it the object\'s only required master_detail), or demote the others (drop ' +
615-
'`required`, or change the relation type). System objects are judged too: the ambiguity is a ' +
616-
'property of the document.',
604+
'ADR-0055 §1 names that master by exactly one `master_detail` field declared `required: true`, ' +
605+
'with a `reference` to the master object.',
606+
'With two or more such fields the declaration does not say which master governs the rows, and the ' +
607+
'platform derives none: every read is DENIED and every write is refused with 422 INVALID_METADATA, ' +
608+
'exactly as for an object with no master at all. Picking one by field declaration order — what ' +
609+
'the runtime once did — would let a cosmetic reordering of the schema move a security boundary to ' +
610+
'another object. System objects are judged too: the ambiguity is a property of the document.',
611+
'To fix it, keep `master_detail` on the ONE master this object\'s rows belong to, and declare every ' +
612+
'other parent as a `lookup` field. `ObjectSchema.create()` makes every `master_detail` on such an ' +
613+
'object required, so a second parent declared as `master_detail` is always a second master.',
617614
],
618615
};
619616

620617
const SECURITY_CBP_NO_RELATION_EXPLANATION: RuleExplanation = {
621618
rule: 'security-controlled-by-parent-no-relation',
622619
covers: 'why the object is unusable without a master',
623620
paragraphs: [
624-
'A `controlled_by_parent` object says its access comes from its master. ADR-0055 resolves the ' +
625-
'master through a required master_detail, then any master_detail, then a required lookup — each ' +
626-
'of which must also name a `reference` target — and an object that matches none of the three has ' +
627-
'nothing to derive access from.',
628-
'Both runtime halves refuse it: every read is DENIED, and every write is refused with 422 ' +
629-
'INVALID_METADATA — as a metadata defect rather than a permission denial — so the object is ' +
630-
'unusable rather than merely locked down. System objects are judged too, because the runtime ' +
621+
'A `controlled_by_parent` object says its access comes from its master. ADR-0055 §1 names that ' +
622+
'master by exactly one `master_detail` field declared `required: true`, with a `reference` to the ' +
623+
'master object, and by nothing else: a `master_detail` without `required: true` and a required ' +
624+
'`lookup` derive no access.',
625+
'Both runtime halves refuse an object without one: every read is DENIED, and every write is refused ' +
626+
'with 422 INVALID_METADATA — as a metadata defect rather than a permission denial — so the object ' +
627+
'is unusable rather than merely locked down. System objects are judged too, because the runtime ' +
631628
'refusal does not exempt them.',
632-
'To fix it, add the master relation this object is derived from, a `master_detail` field with a ' +
633-
'`reference` and `required: true`. An object with no master decides its own baseline: ' +
629+
'To fix it, declare the master relation this object is derived from: a `master_detail` field with a ' +
630+
'`reference` and `required: true` (a lookup to the master becomes that field; a detail is deleted ' +
631+
'with its master unless it declares `deleteBehavior: \'restrict\'`). An object with no master decides ' +
632+
'its own baseline — and so does one whose rows must outlive their parent, which is not a detail: ' +
634633
'\'private\' (the owner plus explicit shares), \'public_read\' or \'public_read_write\'.',
635634
],
636635
};
@@ -667,7 +666,7 @@ const SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION: RuleExplanation = {
667666
'(403) before the parent-derived access is ever consulted — the silent "can\'t submit the ' +
668667
'subtable" trap.',
669668
'A child is an object with a master_detail field, or a `controlled_by_parent` object that resolves ' +
670-
'its master the way the runtime does (a required lookup included). The rule is a warning, and it ' +
669+
'its master the way the runtime does (its one required master_detail). The rule is a warning, and it ' +
671670
'stays silent when the stack authors no permission sets or a `\'*\'` wildcard grant covers every ' +
672671
'object; one set granting the child while another forgets it is out of scope.',
673672
'To fix it, grant the object in at least one permission set that already grants its master — ' +

0 commit comments

Comments
 (0)