Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/adr/0055-master-detail-controlled-by-parent.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
· **Revisited in part** (2026-10-10, [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md), Proposed) — §2's alternatives row (a) is reversed for ONE engine-internal, non-authorable leaf the engine composes from the compiler's output; the RLS compiler still has no subquery form, and §2's chosen mechanism (b), its `rlsMembership` IN-form and the set-size limit all stand.
· **Amended** (2026-10-22, #24932, ruling B `6303721609` and Q1 C / Q2 A `6329027469`) — **§2's chosen mechanism (b) is retired for `controlled_by_parent`, and the set-size limit with it**: the read derivation (`packages/plugins/plugin-security/src/security-plugin.ts#computeControlledByParentFilter`) answers ADR-0139's `ReadableIds` leaf over the master, the engine composes it from this plugin's own `canReadObject` / `getReadFilter` of the master, and the driver compiles one statement with the master inside a subquery — the seventh consumer of that leaf, in the end-state form. No master id set is pre-resolved any more. The 2026-10-10 line above reads as of its date. What still stands: §1 (the one required `master_detail`), §3 (the write gate), the cycle guard and the depth bound (now the engine's own cut, judged first over the registry), and the RLS compiler's `rlsMembership` IN-form, which serves `current_user.<key>` memberships and never served this derivation. The limit that replaces the set-size ceiling is ADR-0139 D5's two master shapes, which the boot refuses (#25144).
· **Amended** (2026-10-26, #25760, ruling `6398566164`) — **§3 gains one per-object opt-in**: `sharing.controlledByParent.ownerMayEdit: true` lets the OWNER of a detail row (its `owner_id`) pass the write gate on a by-id `update` of that row with **read** access to the master; every other caller, and every insert, delete, predicate update and on-behalf-of write, keeps the master-**edit** rule. The default is unchanged. See **"Amendment (2026-10-26, #25760): the owner of a detail row may update it with read on the master, by opt-in"** at the end.
· **Amended** (2026-10-27, #26591 / PR #26658) — **the 2026-10-26 amendment's Lint bullet is narrowed in place**: the lint prescribes the opt-in only for an `owner_id`-keyed `update` / `all` policy on a `controlled_by_parent` detail that carries `owner_id` and has not opted in; any other caller-keyed update policy is flagged in both states with the `owner_id` remedy, never the opt-in. The gate, the spec and the lint are unchanged by this note. See the note under **Lint** in "What the opt-in does, exactly".
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs)
**Surfaced by**: an audit of master-detail permission semantics — `OWDModel.controlled_by_parent` is **declared but unenforced** (zero runtime consumers; not reachable through the object's `sharingModel` enum; the RLS compiler is relationship-blind). This is *false compliance* (ADR-0049) and *unproven liveness* (ADR-0054).
Expand Down Expand Up @@ -174,6 +175,7 @@ An ADR recording a **narrower** enforcement than the runtime performs errs in th
- **Gate.** `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite` gains one leg, before the §3 chain walk, for a **by-id `update`** only. It admits the write when **all** hold: the detail's resolved relation carries the opt-in **and** the registered schema carries the `owner_id` column (`CbpRelation.ownerMayEdit`, resolved beside the relation in `security-plugin-cbp-relation.ts`); the write is **not** on behalf of another user; the stored row's `owner_id` is the caller's own `userId`; and the caller can **read** the master row, asked of the read door itself under the caller's context — the master's object grant, its row-level and sharing scope and, for a master that is itself `controlled_by_parent`, the chain the engine composes (ADR-0139 D6). A hidden row and a refused read are one answer, "not readable" (#7505). When any condition fails, the write takes the §3 walk and carries the walk's own refusal; the leg adds no envelope of its own.
- **What the leg does not change.** Gate ① (the detail's own object-level `update` grant) and step 2.7 (the detail's own row-level policies, where `owner_id == current_user.id` now decides as authored) run before it, unchanged. A body that re-points the master reference still asks **edit** on the master it names: the owner may change their row, not move it under a master they cannot edit. The on-behalf-of path keeps both floors, as ADR-0090 D10 keeps them for every other relief.
- **Lint.** `@objectstack/lint` gains `security-controlled-by-parent-owner-policy-without-opt-in` (`warning`): an `update` / `all` policy that keys a field on `current_user.id`, on a `controlled_by_parent` detail of the stack that has **not** opted in, is flagged with the opt-in as the fix. Advisory, because a narrowing can be meant and no runtime door refuses the shape.
⚠️ **Amended 2026-10-27 (#26591 / PR #26658) — the opt-in is prescribed only where the gate honours it.** The leg above admits one caller, the user in the stored row's `owner_id`, and only on a detail whose registered schema carries that column (`CbpRelation.ownerMayEdit` resolves `false` otherwise), so the arm no longer prescribes the opt-in for every caller-keyed policy. Under the same rule id and the same `warning` tier, `packages/lint/src/validate-security-posture.ts#pushCbpOwnerPolicyWithoutOptIn` prescribes the opt-in only for an `update` / `all` policy that keys the caller on `owner_id`, on a `controlled_by_parent` detail that carries `owner_id` and has **not** opted in — the one shape the opt-in turns into an admission. Every other caller-keyed update policy — one keyed only on another user field (`owner == current_user.id`), or on `owner_id` where the detail carries no such column (`ownership: 'business_unit' | 'org' | 'none'`, or a platform-managed object) — goes to `packages/lint/src/validate-security-posture.ts#pushCbpCallerPolicyOffOwnerColumn` and is flagged in **both** states, opted in or not: the verdict says the opt-in admits only the stored `owner_id`, the remedy is to key the accountable person on `owner_id` (or to give the detail that column), and the opt-in is never named as the fix. The rule's `os explain` entry (`packages/lint/src/rule-explanations.ts#SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION`) says the same. The sentence above is kept as the arm as first landed; ⛔ it is no longer a statement of what the lint prescribes. Whether the runtime leg should widen to a named owner field is the maintainer's open question on #26591; this note records it and decides nothing.

### What is out of scope, and why

Expand Down
Loading