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
13 changes: 13 additions & 0 deletions .changeset/31169-cbp-optional-master-entry-write-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

docs(spec): the semantic migration entry `cbp-optional-master-admitted` states the current write rule for an empty optional master, and the security-service contract names the `ownerMayEdit` owner leg

Clause-②: no
Metadata-protocol: none

- **Migration entry `cbp-optional-master-admitted`.** Its `replacement`, and its paragraph in the protocol upgrade guide, still said that a write leaving an optional `controlled_by_parent` master empty is refused with 422. The write gate admits such writes since the owner rule landed (ADR-0055 §3 as amended): an insert that names no master is admitted and its creator owns the row; its owner and a holder of Modify All on the detail update or delete a row whose master reference is empty, and everyone else, View All included, is refused `403 PERMISSION_DENIED`; an update that empties a named master asks edit access on the master it leaves. Only a required master's empty stored reference is still refused with `422 MISSING_REQUIRED_FIELD`. This supersedes the sentence "Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422" in the `@objectstack/plugin-security` note of `.changeset/30905-cbp-optional-master.md`: that was the state before the write gate followed, and the rule above is the one that ships. The entry's `id`, `surface`, `reason` and `acceptanceCriteria` are unchanged.
- **`ISecurityService.checkControlledByParentWrite` and `ControlledByParentWriteOutcome` (JSDoc).** Both now name the `sharing.controlledByParent.ownerMayEdit: true` opt-in (ADR-0055 §3 as amended 2026-10-26): a by-id update by the record's owner, not on behalf of another user, that does not empty an optional master, needs only read access to the master the record names, so the check answers `allow` for such an owner who can read it. Every other caller and every other write keeps the master-edit rule.

Text only: no schema, type, export or runtime answer changes.
23 changes: 20 additions & 3 deletions packages/spec/src/contracts/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,14 @@ export type ControlledByParentWriteUnresolvedReason =
* chain for the principal (and, on an on-behalf-of context, for the delegator
* too), and where the chain ends at a row whose OPTIONAL master reference is
* empty (the record itself, or a master above it) that row's owner rule
* admits them too. Or the context is a system one, which the write path
* never checks.
* admits them too. Or the detail opts in with
* `sharing.controlledByParent.ownerMayEdit: true` (ADR-0055 §3 as amended
* 2026-10-26) and its owner leg admits the principal in place of the walk:
* the context is not on behalf of another user, the record's stored
* `owner_id` is the principal's own `userId`, and the principal can READ the
* master the record names, so no leg asks EDIT on that master or any master
* above it. Or the context is a system one, which the write path never
* checks.
* - `deny`: the check refuses, on the {@link ControlledByParentWriteDenialLeg}
* it names.
* - `not_applicable`: the object does not declare `controlled_by_parent`, so
Expand Down Expand Up @@ -1086,7 +1092,18 @@ export interface ISecurityService {
* security, and record sharing (whose refusal an app-authored update policy
* on the master can lift, {@link checkAuthoredRowWrite}). A master whose own
* optional master reference is empty ends the chain, and the same owner rule
* is asked of it on its own object. This member exposes that check
* is asked of it on its own object. A detail that declares
* `sharing.controlledByParent.ownerMayEdit: true` relaxes the edit rule for
* one write (ADR-0055 §3 as amended 2026-10-26): a by-id update by the
* record's owner (its stored `owner_id` is the caller's own `userId`), not on
* behalf of another user, that does not empty an optional master, needs only
* READ access to the master the record names, asked of the read door under
* the caller's own context, and walks no chain for that master; a body that
* re-points the row under another master still asks EDIT on that master, as
* every body naming a new master does. Every other caller, and every
* insert, delete, predicate update and on-behalf-of write, keeps the edit
* rule. The leg is part of the check, so such an owner who can read the
* master gets `allow` here as on the update. This member exposes that check
* to code outside the write path, such as a gate on a record's attachments or
* comments that must judge the record as its own update is judged. It adds no
* verdict: every outcome it resolves with is one the check already reaches,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import type { SemanticMigration } from '../../types.js';
// its meaning where the spec sees the omission (the builder force and the D2
// conversion cbp-master-detail-required-defaulted, both unchanged). No D2
// conversion: nothing is rewritten.
//
// The write side followed under ADR-0055 §3 as amended (same ruling A, PR
// #31082), and the `replacement` below records it beside the read side: a
// detail row whose OPTIONAL master reference is empty is written by its owner
// and by Modify All on the detail and refused `403 PERMISSION_DENIED` for
// everyone else, an insert naming no master is admitted, and only a REQUIRED
// master's empty stored reference still answers `422 MISSING_REQUIRED_FIELD`.
export const entry: SemanticMigration = {
id: 'cbp-optional-master-admitted',
surface: 'object.sharingModel = \'controlled_by_parent\' together with object.fields — a `master_detail` '
Expand All @@ -21,9 +28,15 @@ export const entry: SemanticMigration = {
+ 'field with `required: false`, an optional master. A detail row that names its master is read exactly as '
+ 'before, by whoever can read that master. A row whose master reference is empty is read by its owner '
+ '(`owner_id`) and by a holder of View All on the detail, and by nobody else: a row with no owner, or on '
+ 'an object that carries no `owner_id` column, is read through View All alone. Writes are unchanged by '
+ 'this entry: an insert or update that leaves the master reference empty is still refused with 422. '
+ 'Declare `required: true` when every row must name its master; an omitted `required` keeps meaning '
+ 'an object that carries no `owner_id` column, is read through View All alone. Writes follow the same '
+ 'owner: an insert that names no master is admitted, and its creator owns the row; its owner and a '
+ 'holder of Modify All on the detail update or delete a row whose master reference is empty, and '
+ 'everyone else who reaches the write gate, View All included, is refused `403 PERMISSION_DENIED`; a '
+ 'row with no owner, or on an object that carries no `owner_id` column, is written through Modify All '
+ 'alone. An update that empties a named master asks edit access on the master it leaves. A required '
+ 'master is unchanged: a row whose stored required master reference is empty is still refused with '
+ '`422 MISSING_REQUIRED_FIELD`. Declare `required: true` when every row must name its master; an '
+ 'omitted `required` keeps meaning '
+ 'required through `ObjectSchema.create()` and on stored rows, and `os lint` reports it at `error` so the '
+ 'author writes one of the two.',
reason:
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/src/migrations/registry.ts.template
Original file line number Diff line number Diff line change
Expand Up @@ -1327,8 +1327,10 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
+ 'master from it, and a detail row that names no master is read by its owner and by View All on the '
+ 'detail, never through a master, while a row that names one is read through it as before. The builder '
+ 'keeps an explicit `required: false` as written and still forces an omitted one to `true`, and the '
+ 'stored-row listing stops naming the shape. Writes are unchanged: an empty master reference is still '
+ 'refused with 422. Its D3 record is the semantic entry `cbp-optional-master-admitted`.',
+ 'stored-row listing stops naming the shape. A detail row that names no master is written by its '
+ 'owner and by Modify All on the detail, and refused with 403 for everyone else; only an empty '
+ 'required master reference is still refused with 422. Its D3 record is the semantic entry '
+ '`cbp-optional-master-admitted`.',
},
{
id: 'chart-config-aria-retired',
Expand Down
Loading