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
37 changes: 37 additions & 0 deletions .changeset/23100-cbp-one-required-master.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/plugin-security": minor
---

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)

Clause-②: no (narrowing)

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).

**BREAKING — what moves for consumers.**

- **`@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.
- **`@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".
- **`@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.

**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.

| object | FROM | TO |
|---|---|---|
| `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` |
| `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` |
| `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' })` |

`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`.

**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`.

**Unchanged.**

- 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.
- 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.
- No export, signature or error code is added or removed.

<!-- adr-0087: registered cbp-master-detail-required-defaulted, cbp-master-relation-tiers-retired -->
20 changes: 11 additions & 9 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,16 @@ record, so the gate authorizing writes to it resolves that master *before* the e
and the executor is where required-field validation runs. Which of the two refuses first
depends on how the master reference is declared. A `master_detail` that is `required` and
neither `readonly` nor `system` reaches validation and answers the documented
`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and
`400 VALIDATION_FAILED` with `fields`. The other two declarable shapes never reach it and
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:

- a `master_detail` with no `required`
- a `master_detail` that is `required` + `readonly`
- a `master_detail` that is `required` + `system`
- a `required` `lookup`, when the object declares no `master_detail`

A `master_detail` with no `required`, and a `required` `lookup` on an object that declares no
`master_detail`, are not master references at all: the master is exactly one `master_detail`
declared `required: true` (ADR-0055 §1). `ObjectSchema` refuses such an object at parse, and
one that reaches the runtime unparsed answers every write `422 INVALID_METADATA`.

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

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

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

### `INVALID_FORMAT`
Expand Down
11 changes: 7 additions & 4 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -517,10 +517,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
wider than internal (`security-external-wider-than-internal`), `'*'`
wildcards carrying View/Modify All outside the platform admin set,
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
"role" in security identifiers, a `controlled_by_parent` object with no
relation the platform can derive access from (ADR-0055: no required
`master_detail`, no `master_detail` at all, and no required `lookup` — so
the runtime denies every read and refuses every write), and the ADR-0091
"role" in security identifiers, a `controlled_by_parent` object without
exactly one required `master_detail` to derive access from (ADR-0055 §1:
`security-controlled-by-parent-no-relation` when it declares none — a
`master_detail` without `required: true` and a required `lookup` derive
nothing — and `security-controlled-by-parent-ambiguous-relation` when it
declares two or more; either way the runtime denies every read and refuses
every write), and the ADR-0091
grant-lifecycle rules (a seed grant already expired at authoring time; a
delegation row missing its mandatory `reason`) — every error rule mirrors
a runtime gate.
Expand Down
27 changes: 16 additions & 11 deletions content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -497,35 +497,40 @@ depends on how the reference is declared:
| Master reference declared as | Refused by | Status | `code` | `fields` |
|---|---|:---:|---|:---:|
| `master_detail` + `required`, not `readonly`/`system` | required-field validation | 400 | `VALIDATION_FAILED` | yes |
| `master_detail` with no `required` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
| `master_detail` + `required` + `readonly` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
| `master_detail` + `required` + `system` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
| a `required` `lookup`, when the object declares no `master_detail` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |

Only the first row is the documented 400 shape. On the other four, messages are prefixed
Only the first row is the documented 400 shape. On the other two, messages are prefixed
`[Security] Missing master reference:` and name the object and the field, but no `fields`
array rides along.

Two declarations are not rows of this table, because they name **no master at all**. A
`controlled_by_parent` object derives its master from exactly one `master_detail` field
declared `required: true` (ADR-0055 §1), so an object whose only relation is a `master_detail`
without `required`, or a `required` `lookup`, derives none — and neither does one with two
required `master_detail` fields. `ObjectSchema` refuses such an object at parse, naming it;
one that reaches the runtime unparsed has every read denied and every write refused with
`422 INVALID_METADATA`, whatever the request carries.

The same `422 MISSING_REQUIRED_FIELD` also answers an update or delete **by id** whose
*stored* master reference is null, whatever the declaration. That is a different path: the
caller supplied no such field, so there is no request field to name in `fields` and no
payload that would fix it.

**Why the gate refuses rather than handing over.** Required-field validation skips
provenance-flagged fields before its required check is reached (`system` and `readonly`
fields are skipped outright) and never fires on a field that is not `required` at all. On
those four shapes the gate is the only thing refusing the write, and letting it through was
fields are skipped outright). On those two shapes the gate is the only thing refusing the
write, and letting it through was
measured to create a detail record whose master reference is null — a record the
`controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, so it is
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.** `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.
**These shapes are authorable today.** `os lint` refuses both 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. 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
Expand Down
47 changes: 23 additions & 24 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,39 +595,38 @@ const DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {

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

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