diff --git a/docs/adr/0055-master-detail-controlled-by-parent.md b/docs/adr/0055-master-detail-controlled-by-parent.md index 6b752b1683c..c619458dd65 100644 --- a/docs/adr/0055-master-detail-controlled-by-parent.md +++ b/docs/adr/0055-master-detail-controlled-by-parent.md @@ -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.` 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). @@ -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