Skip to content
18 changes: 18 additions & 0 deletions .changeset/25760-controlled-by-parent-owner-may-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-security': minor
'@objectstack/lint': minor
---

feat(spec,plugin-security,lint): a `controlled_by_parent` detail may opt its rows' owners into updating them with read access to the master — `sharing.controlledByParent.ownerMayEdit: true`

Clause-②: yes (widening) — a detail row's owner now passes the master-detail write gate with READ on the master, on an object that declares the opt-in; everyone else, and every other shape of write, still needs master EDIT. The default is unchanged.
Metadata-protocol: added (`ObjectSchema.sharing.controlledByParent.ownerMayEdit`, a per-object opt-in read only under `sharingModel: 'controlled_by_parent'`)
Ruling-ref: 6398566164 (letter 1)

ADR-0055 §3 had one rule for every write of a `controlled_by_parent` detail: EDIT access to the master record. Salesforce's Activities under OWD = Controlled by Parent let the owner of an activity edit it while merely reading its account; the platform could not express that, and an authored `owner_id == current_user.id` update policy on the detail could not either — the detail's own policies compose AND with the master gate, so it only narrowed the master's editors.

- **Spec.** `ObjectSchema` gains `sharing: { controlledByParent: { ownerMayEdit: boolean } }`, both blocks closed. `ownerMayEdit: true` is refused at parse on any object that is not a `sharingModel: 'controlled_by_parent'` detail, at `sharing.controlledByParent.ownerMayEdit`. The reader `controlledByParentOwnerMayEdit(schema)` (`@objectstack/spec/data`) fails closed on every shape but the parsed `true`.
- **Gate.** On an opted-in detail, a by-id `update` by the caller whose `userId` is the stored row's `owner_id` (the registry's injected per-record owner) passes step 2.8 when the caller can READ the master row, asked of the read door under the caller's own context. Insert, delete, a predicate (`multi: true`) update and a delegated (on-behalf-of) write keep the master-edit rule; a body that re-points the master reference still asks EDIT on the master it names; a row with no owner admits nobody. The detail's own object grant and row-level policies run before the leg, unchanged. An opt-in on an object whose schema carries no `owner_id` column (`ownership` other than `'user'`) is inert and reported once per object at `warn` by the relation resolver.
- **Lint.** `os lint` reports `security-controlled-by-parent-owner-policy-without-opt-in` (warning) for an `update` or `all` row-level policy whose `using` or `check` keys a field on the caller (`owner_id == current_user.id`, either operand order, the SQL `=` spelling included) on a `controlled_by_parent` detail that has not declared the opt-in. `os explain security-controlled-by-parent-owner-policy-without-opt-in` prints why such a policy can only narrow, and what the opt-in changes.
- **ADR-0055** §3 is amended (2026-10-26, #25760) with the opt-in, its boundaries, and what stays out of scope.
1 change: 1 addition & 0 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,7 @@ Metadata query with filtering, sorting, and pagination
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). |
| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. |
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down
7 changes: 7 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const result = ApiMethod.parse(data);
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). |
| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. |
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down Expand Up @@ -436,6 +437,12 @@ const result = ApiMethod.parse(data);
| **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning |
| **approvalsVisibleToReaders** | `boolean` | optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it |

### Nested Shape: `Object.sharing`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **controlledByParent** | `{ ownerMayEdit?: boolean }` | optional | Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit. |

### Nested Shape: `Object.defaultRowScope`

| Property | Type | Required | Description |
Expand Down
Loading
Loading