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
10 changes: 10 additions & 0 deletions .changeset/data-skill-optional-master.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): the data skill teaches `controlled_by_parent` with an optional master

Clause-②: no
Metadata-protocol: none

`objectstack-data` now teaches "a rep sees the contacts of the accounts they own; a contact need not have an account" as a `controlled_by_parent` detail whose only `master_detail` is declared `required: false` (an optional master), not as an owner field copied by hooks. A row with no master is its owner's: the owner or View All on the detail reads it, the owner or Modify All writes it, an insert naming no master is admitted, setting a master asks edit on it, emptying one asks edit on the stored master (`ownerMayEdit` does not cover that), and a chain hop with no master ends there under the same rule. The `relationship/master-detail-required` lint row names the optional master, and the membership-key notes say a `valueField` cannot be the roster's own `id` (`membership-key-value-field-invalid`). The platform behaviour shipped earlier in `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-security`; this release only teaches it.
13 changes: 8 additions & 5 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -537,11 +537,14 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
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 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
exactly one master to derive access from (ADR-0055 §1 as amended: one
`master_detail` declared `required: true`, or, with none, its only
`master_detail` declared `required: false`, an optional master;
`security-controlled-by-parent-no-relation` when it declares none — two
`master_detail` fields with none required, one that writes no `required`,
and a required `lookup` derive nothing — and
`security-controlled-by-parent-ambiguous-relation` when it declares two or
more required ones; 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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ Complete security setup for a sales team:
const OrganizationDefaults = {
account: 'private',
opportunity: 'private',
contact: 'controlled_by_parent',
contact: 'controlled_by_parent', // its master_detail to account; with required: false, a contact with no account is its owner's (ADR-0055 §1)
};

// 2. Positions (flat — the hierarchy lives on business units)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ OWD sets the baseline access level for each object across the entire organizatio
| **Public Read/Write** | `public_read_write` | All users | All users | Low-sensitivity data (e.g., tasks, wiki pages) |
| **Public Read Only** | `public_read` | All users | Owner + shared | Moderate sensitivity (e.g., accounts, contacts) |
| **Private** | `private` | Owner + shared | Owner + shared | High sensitivity (e.g., opportunities, HR records) |
| **Controlled by Parent** | `controlled_by_parent` | Derived from the master record | Derived from the master record | Master-detail children (e.g., order lines) |
| **Controlled by Parent** | `controlled_by_parent` | Derived from the master record; a row with no (optional) master: its owner + View All | Derived from the master record; a row with no (optional) master: its owner + Modify All | Master-detail children (e.g., order lines; a contact whose account is optional) |

<Callout type="warn">
These are the **only four canonical values** (ADR-0090 D4). The legacy spellings
Expand Down
5 changes: 4 additions & 1 deletion content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ Layer 1 (business RLS) ─┘
- A superuser bypasses business policies but **not** tenant isolation —
crossing tenants requires a genuine platform admin.
- Children of a master-detail parent with `sharingModel: 'controlled_by_parent'`
derive access from the master record instead.
derive access from the master record instead. A child whose master is
optional (`required: false`, ADR-0055 §1 as amended) and empty is read by its
owner and by View All on the child, and written by its owner and by Modify
All on the child.

## The fail-closed contract

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const LeaveRequest = ObjectSchema.create({
| `private` | Owner only (+ scope-depth grants and record shares) |
| `public_read` | All users can read; writes stay owner-scoped |
| `public_read_write` | All users can read and edit |
| `controlled_by_parent` | Access follows the master record (master-detail children) |
| `controlled_by_parent` | Access follows the master record (master-detail children); a row whose optional master is empty is its owner's, plus View All / Modify All on the object |

> **The default is `private`, fail-closed (ADR-0090 D1).** A custom object
> that declares **no `sharingModel`** — or that stores an unknown value — is
Expand Down
30 changes: 26 additions & 4 deletions docs/adr/0055-master-detail-controlled-by-parent.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
· **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".
· **Amended** (2026-11-06, #30905, maintainer ruling 「A」) — **§1 admits an optional master and §2 gains a null-master arm**: a `controlled_by_parent` object whose only `master_detail` is declared `required: false` derives an OPTIONAL master. A detail row that names its master is read through it, unchanged; a row whose master reference is empty is read by its owner (`owner_id`) and by View All on the detail, through a second arm OR-ed with the master arm inside the one read chokepoint, `computeControlledByParentFilter`. §3 (the write gate) is unchanged by this note: a write that leaves the reference empty is still refused. See **"Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads"** at the end.
· **Amended** (2026-11-06, #30905, maintainer ruling 「A」) — **§1 admits an optional master and §2 gains a null-master arm**: a `controlled_by_parent` object whose only `master_detail` is declared `required: false` derives an OPTIONAL master. A detail row that names its master is read through it, unchanged; a row whose master reference is empty is read by its owner (`owner_id`) and by View All on the detail, through a second arm OR-ed with the master arm inside the one read chokepoint, `computeControlledByParentFilter`. §3 (the write gate) is unchanged by this note: a write that leaves the reference empty is still refused. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082)** — that §3 sentence no longer holds; see the next line. See **"Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads"** at the end.
· **Amended** (2026-11-07, #30906 / PR #31082, the same ruling 「A」) — **§3 judges the null-master row by an owner rule**: a row of an optional master whose reference is empty is written by its owner (`owner_id`) and by Modify All on the detail, everyone else `403`; an insert naming no master is admitted; setting a master asks edit on it, emptying one asks edit on the stored master (the 2026-10-26 opt-in does not cover it); a chain hop with no master of its own ends the chain under the same rule. A required master is unchanged. See **"Amendment (2026-11-07, #30906): the null-master row its owner writes"** at the end.
**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 @@ -66,6 +67,7 @@ In the pre-image block (`packages/plugins/plugin-security/src/security-plugin.ts
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
- **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2).
⚠️ **Amended 2026-10-26 (#25760) — one opt-in.** A detail that declares `sharing.controlledByParent.ownerMayEdit: true` lets the **owner** of a detail row (`owner_id`) pass this gate on a **by-id `update`** of that row with **read** access to the master. The rule above stands for every other caller and every other write; see the Amendment at the end before deriving an invariant from this line.
⚠️ **Amended 2026-11-07 (#30906 / PR #31082) — the null-master row.** For an **optional** master (§1 as amended), a row whose master reference is empty has no master to ask: its owner or Modify All on the detail writes it, an insert naming no master is admitted, and emptying a master asks edit on the stored master. See the 2026-11-07 amendment at the end.

This reuses the existing re-read-and-deny pattern; no new enforcement layer.

Expand Down Expand Up @@ -199,7 +201,7 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to

## Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads

**Ruling.** objectstack-ai/objectstack#30905, maintainer ruling 「A」 (2026-11-06): `controlled_by_parent` admits an optional master, and a detail row with no master is visible only to its owner — the Salesforce reading of a contact with no account, a private contact. Rejected with it: a membership key collecting its object's own id, one-hop RLS traversal, and no platform change. The ruling lands in three serial changes; this amendment records the first (spec, lint, the read). The write gate (§3) and the docs follow in their own changes, and this record is amended again for §3 then.
**Ruling.** objectstack-ai/objectstack#30905, maintainer ruling 「A」 (2026-11-06): `controlled_by_parent` admits an optional master, and a detail row with no master is visible only to its owner — the Salesforce reading of a contact with no account, a private contact. Rejected with it: a membership key collecting its object's own id, one-hop RLS traversal, and no platform change. The ruling lands in three serial changes; this amendment records the first (spec, lint, the read). The write gate (§3) and the docs follow in their own changes, and this record is amended again for §3 then. ⚠️ **Amended 2026-11-07:** it was — see the 2026-11-07 amendment (#30906 / PR #31082).

### The gap this closes

Expand All @@ -225,8 +227,28 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to

- An omitted `required` on an object that reached the registry without the parse resolves no master (the runtime reads `required === false`, not its absence), so every read is denied, as before.
- A cycle or a depth refusal of the chain still denies the whole object, the null-master rows included: the declared chain is the defect.
- The write gate is unchanged: an insert or update that leaves the master reference empty is refused (`MasterReferenceMissingError`, 422), so no null-master row can be written through the platform until §3 is amended.
- The write gate is unchanged: an insert or update that leaves the master reference empty is refused (`MasterReferenceMissingError`, 422), so no null-master row can be written through the platform until §3 is amended. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082) — no longer:** for an optional master §3 now judges such a write by the owner rule; a required master's empty reference is still refused. See the 2026-11-07 amendment.

### What is unchanged

§3, the chain walk, the cycle guard and the depth bound; every `controlled_by_parent` object with a required master, byte for byte; the owner opt-in of the 2026-10-26 amendment.
§3, the chain walk, the cycle guard and the depth bound; every `controlled_by_parent` object with a required master, byte for byte; the owner opt-in of the 2026-10-26 amendment. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082):** §3 and the chain walk changed for the null-master row; see the 2026-11-07 amendment.

## Amendment (2026-11-07, #30906): the null-master row its owner writes

**Ruling.** The same maintainer ruling 「A」 of #30905 (2026-11-06): a detail row with no master is its owner's. The 2026-11-06 amendment recorded the first of three changes (spec, lint, the read) and left §3 refusing every write that leaves an optional master empty. The second, #30906 (PR #31082, merged 2026-11-07), amends §3; this section records it. The third, #30907, teaches it in the docs and the authoring skill.

### What §3 does now, exactly

The gate (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite`) judges a row of an **optional** master whose master reference is empty instead of refusing it `422`:

- **The owner rule** (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#nullMasterRowWritable`). Such a row is written by its owner (`owner_id`, non-null, equal to the principal) and by a holder of Modify All (`modifyAllRecords`) on the detail. Everyone else who reaches the gate, View All included, is refused `403 PERMISSION_DENIED`, leg `master_chain`, naming the row; a principal who cannot read the row meets the read door first (`404 RECORD_NOT_FOUND`, step 2.7), as for every hidden row. A row with no owner (an ADR-0142 queue row), and every row of an object with no `owner_id` column, is Modify All's alone. It is the write twin of §2's null-master arm: View All reads such a row, Modify All writes it.
- **Insert.** An insert, single or batch, that names no optional master is admitted, and its creator becomes its owner. Every master a batch's rows do name is still asked.
- **By-id update and delete** of a stored null-master row, and each null-master row a predicate write (`multi: true`) selects: the owner rule.
- **Setting a master** on such a row asks the row's owner rule, then edit on the new master. **Emptying one** asks edit on the stored master through the §3 walk; the 2026-10-26 opt-in (`ownerMayEdit`) does not cover a body that empties an optional master.
- **Chains.** A hop whose master is itself `controlled_by_parent` with an optional master of its own, left empty, ends the chain there and asks that row's owner rule, on its own object.
- **On behalf of.** Each pass (agent, then delegator) asks the owner rule of its own principal. `ISecurityService.checkControlledByParentWrite` answers such a row `allow`, or `deny` with leg `master_chain`, never `unresolvable`.
- **Proof (§4, ADR-0054).** `packages/verify/src/parent-write-reach.ts` proves an optional-master detail both under a master and with none (`masters: 'none'`), with the owner rule as the expectation.

### What is unchanged

Every `controlled_by_parent` object with a required master, byte for byte: its empty reference is still refused (`422 MISSING_REQUIRED_FIELD`). Ownership transfer stays on `allowTransfer`. §1, §2, the cycle guard and the depth bound.
4 changes: 1 addition & 3 deletions skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ the publish door rejects it before that:
| `private` | owner only (widen with sharing rules, RLS, or `readScope`/`writeScope`) |
| `public_read` | everyone reads; the owner writes |
| `public_read_write` | everyone reads and writes |
| `controlled_by_parent` | inherited from the master record (`master_detail` children) |
| `controlled_by_parent` | inherited from the master record (`master_detail` children); a row with no (optional) master is its owner's |

**Important optional properties:**

Expand Down Expand Up @@ -291,8 +291,6 @@ via the registered `ICryptoProvider`, masked on read, fail-closed, ADR-0100) —
| Many-to-Many (with attributes) | Junction object with two `lookup` fields |
| Hierarchical | `tree` field (self-reference) |

See [rules/relationships.md](./rules/relationships.md) for detailed examples.

> **`multiple: true` lookup ≠ junction object.** A multi-value lookup
> (`{ type: 'lookup', reference: 'x', multiple: true }`) is stored and read as an
> **array of ids** on the record — reference elements positionally
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-data/references/lint-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Data-model rules (in addition to naming/label/i18n):
| Rule | Severity | Catches |
|---|---|---|
| `relationship/missing-reference` | error | lookup/master_detail without a `reference` target |
| `relationship/master-detail-required` | warning; error under `controlled_by_parent` | a `master_detail` that isn't `required` — or, under `controlled_by_parent`, is `readonly`/`system` |
| `relationship/master-detail-required` | warning; error under `controlled_by_parent` | a `master_detail` that isn't `required` (under `controlled_by_parent`, the only one may be `required: false`: the optional master) — or, under `controlled_by_parent`, is `readonly`/`system` |
| `relationship/delete-behavior` | suggestion | `master_detail` without an explicit `deleteBehavior` |
| `relationship/line-items-inline-edit` | suggestion | a `*_line`/`*_item` master_detail child without `inlineEdit` |
| `relationship/line-item-should-be-master-detail` | suggestion | a line-item-shaped child using `lookup` instead of `master_detail` |
Expand Down
Loading
Loading