Skip to content

Commit 2b752f1

Browse files
docs(spec): the cbp-optional-master-admitted entry states the current write rule, and the security-service contract names ownerMayEdit (#31210)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGML1ZArFc2ffDxPnNbY42U)_ Fixes #31169 Fixes #31180 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:两处纯文字。① 迁移登记里 `cbp-optional-master-admitted` 这一条的说明文字(`replacement` 与文件头注释),以及升级指南里同一条的那段叙述:不再说「主表引用为空的写入一律 422」,改为现行规则——可选主表为空的行由属主或该子对象「修改全部」写,其他人(含「查看全部」)403;不挂主表的新建放行;只有必填主表为空仍是 422。② 安全服务契约(`security-service.ts`)的 `allow` 一条和 `checkControlledByParentWrite` 说明补上「属主可编辑」开关(`ownerMayEdit`):开了它,记录属主本人按 id 更新、非代办、不清空可选主表时,只要能读主记录即可。 - **为什么改**:PR #31082 已按您对 #30905 的裁决「A」放开写门,这条迁移说明还停在读侧落地时的旧句子,`os migrate meta` 和据此生成的发布说明会告诉作者错误的答案;契约注释则从没写过 #25760 裁决的属主开关,读起来像「一律要主记录编辑权」。 - **风险与回滚**:纯文字,加一个 `@objectstack/spec` patch changeset;`id`、`surface`、`reason`、`acceptanceCriteria`、schema 与运行时一概不动。回滚即 revert。 - **先看**:`packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts` 的 `replacement`;`packages/spec/src/contracts/security-service.ts` 的 `allow` 一条。 - **两分钟验证**:`git diff origin/main -- packages/spec/src/`,预期只有这条迁移的说明字符串、头注释、模板里同一段叙述和两段 JSDoc 变化。 - **需要你做的**:无,席位落地。 ## What changes Release text only, measured against the write gate PR #31082 landed (`037371559b`, `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts`: `nullMasterRowWritable`, the `masterId == null && rel.optional` branch of `assertControlledByParentWrite`, and its `403 PERMISSION_DENIED` refusal `nullMasterOwnerDenied`): 1. `packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts`, `replacement`: the sentence "Writes are unchanged by this entry: an insert or update that leaves the master reference empty is still refused with 422" is replaced by the current rule. 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 without an `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 stored empty required master reference is still refused with `422 MISSING_REQUIRED_FIELD`. The wording follows `content/docs/protocol/kernel/error-handling.mdx` and `content/docs/api/error-catalog.mdx` (PR #31164) and the permissions pages of PR #31092, so the four texts agree. The replacement string carries no tracker number (`check:doc-authoring`). 2. Same file, header comment: a paragraph naming the write side, ADR-0055 §3 as amended, the same ruling A, citing PR #31082 beside the read-side citation. 3. `packages/spec/src/migrations/registry.ts.template`, the step-18 rationale fragment `cbp-optional-master-admitted` (the upgrade-guide paragraph whose D3 record is this entry): "Writes are unchanged: an empty master reference is still refused with 422" now reads that a detail row naming no master is written by its owner and by Modify All on the detail and refused with 403 for everyone else, and only an empty required master reference is still refused with 422. Fixed in place under os-dev rule 3's bounded exemption (same stale sentence about the same entry, mechanical, no other claim on the paragraph, the same `check:upgrade-guide` / `check:spec-changes` family). The claim's file surface does not name this file; the seat is asked to amend it. 4. **#31180 (folded, its own commit):** `packages/spec/src/contracts/security-service.ts`, JSDoc only. The `allow` bullet of `ControlledByParentWriteOutcome` and the `ISecurityService.checkControlledByParentWrite` docblock now name the `sharing.controlledByParent.ownerMayEdit: true` opt-in, citing ADR-0055 §3 as amended 2026-10-26. Conditions measured from the owner leg in `assertControlledByParentWrite` (`security-plugin-cbp-write-gate.ts`, the `operation === 'update' && detailRow !== null && rel.ownerMayEdit && …` branch): a by-id update; not on behalf of another user (`opCtx.context.onBehalfOf.userId` absent); the stored `owner_id` is the caller's `userId`; the body does not empty an optional master; and the caller can read the master row through the read door (`readUnlessRefused(readRowById(rel.master, masterId, context))`). When it holds, the gate returns before the chain walk, so no master is asked for edit. The served member builds a body-less by-id update with the caller's context, so it takes the same leg and answers `allow`. 5. `.changeset/31169-cbp-optional-master-entry-write-text.md`: one `'@objectstack/spec': patch` covering both cards. The registry ships in the package (`dist`, `spec-changes.json`, `protocol-upgrade-guide.md` are in `files[]`) and so does the contract's JSDoc (in `dist`). Unchanged: the entry's `id`, `surface`, `reason` and `acceptanceCriteria`; every type, export and runtime answer. ## Tests No behaviour change, so no test is added and none is ablated. The opt-in leg the JSDoc describes is pinned by `packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts`. `grep -rn "still refused with 422" packages/` and a search for the template's phrasing found no pin or snapshot quoting the old sentences. The behaviour the new text describes is pinned by `packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts` (PR #31082). The regenerated (gitignored) `packages/spec/src/migrations/registry.ts` carries the new text and no "Writes are unchanged" sentence. ## Acceptance notes - `.changeset/30905-cbp-optional-master.md` (pending, from the read-side PR) still ends its plugin-security bullet with "Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422." It was true of that PR, and `.changeset/30906-cbp-null-master-write-gate.md` states the write change, so both will compile into the same release's notes. Editing a landed changeset is outside this card; noted for the seat, not changed here. ## Verification Local gates on the final head `d3adb0d1`: 78 derived by `dispatch-gates --commands`, 77 exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3: it needs every package's `dist`, CI's). `--ran` reconciles 78 accounted, 0 unrun. `pnpm --filter @objectstack/spec run typecheck` exit 0; `vitest run src/migrations/ src/contracts/` 57 files / 673 tests passed. Details in the card's `os-dev-report`; CI runs the full set. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f017e2 commit 2b752f1

4 files changed

Lines changed: 53 additions & 8 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **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.
11+
- **`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.
12+
13+
Text only: no schema, type, export or runtime answer changes.

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,14 @@ export type ControlledByParentWriteUnresolvedReason =
405405
* chain for the principal (and, on an on-behalf-of context, for the delegator
406406
* too), and where the chain ends at a row whose OPTIONAL master reference is
407407
* empty (the record itself, or a master above it) that row's owner rule
408-
* admits them too. Or the context is a system one, which the write path
409-
* never checks.
408+
* admits them too. Or the detail opts in with
409+
* `sharing.controlledByParent.ownerMayEdit: true` (ADR-0055 §3 as amended
410+
* 2026-10-26) and its owner leg admits the principal in place of the walk:
411+
* the context is not on behalf of another user, the record's stored
412+
* `owner_id` is the principal's own `userId`, and the principal can READ the
413+
* master the record names, so no leg asks EDIT on that master or any master
414+
* above it. Or the context is a system one, which the write path never
415+
* checks.
410416
* - `deny`: the check refuses, on the {@link ControlledByParentWriteDenialLeg}
411417
* it names.
412418
* - `not_applicable`: the object does not declare `controlled_by_parent`, so
@@ -1086,7 +1092,18 @@ export interface ISecurityService {
10861092
* security, and record sharing (whose refusal an app-authored update policy
10871093
* on the master can lift, {@link checkAuthoredRowWrite}). A master whose own
10881094
* optional master reference is empty ends the chain, and the same owner rule
1089-
* is asked of it on its own object. This member exposes that check
1095+
* is asked of it on its own object. A detail that declares
1096+
* `sharing.controlledByParent.ownerMayEdit: true` relaxes the edit rule for
1097+
* one write (ADR-0055 §3 as amended 2026-10-26): a by-id update by the
1098+
* record's owner (its stored `owner_id` is the caller's own `userId`), not on
1099+
* behalf of another user, that does not empty an optional master, needs only
1100+
* READ access to the master the record names, asked of the read door under
1101+
* the caller's own context, and walks no chain for that master; a body that
1102+
* re-points the row under another master still asks EDIT on that master, as
1103+
* every body naming a new master does. Every other caller, and every
1104+
* insert, delete, predicate update and on-behalf-of write, keeps the edit
1105+
* rule. The leg is part of the check, so such an owner who can read the
1106+
* master gets `allow` here as on the update. This member exposes that check
10901107
* to code outside the write path, such as a gate on a record's attachments or
10911108
* comments that must judge the record as its own update is judged. It adds no
10921109
* verdict: every outcome it resolves with is one the check already reaches,

‎packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import type { SemanticMigration } from '../../types.js';
1313
// its meaning where the spec sees the omission (the builder force and the D2
1414
// conversion cbp-master-detail-required-defaulted, both unchanged). No D2
1515
// conversion: nothing is rewritten.
16+
//
17+
// The write side followed under ADR-0055 §3 as amended (same ruling A, PR
18+
// #31082), and the `replacement` below records it beside the read side: a
19+
// detail row whose OPTIONAL master reference is empty is written by its owner
20+
// and by Modify All on the detail and refused `403 PERMISSION_DENIED` for
21+
// everyone else, an insert naming no master is admitted, and only a REQUIRED
22+
// master's empty stored reference still answers `422 MISSING_REQUIRED_FIELD`.
1623
export const entry: SemanticMigration = {
1724
id: 'cbp-optional-master-admitted',
1825
surface: 'object.sharingModel = \'controlled_by_parent\' together with object.fields — a `master_detail` '
@@ -21,9 +28,15 @@ export const entry: SemanticMigration = {
2128
+ 'field with `required: false`, an optional master. A detail row that names its master is read exactly as '
2229
+ 'before, by whoever can read that master. A row whose master reference is empty is read by its owner '
2330
+ '(`owner_id`) and by a holder of View All on the detail, and by nobody else: a row with no owner, or on '
24-
+ 'an object that carries no `owner_id` column, is read through View All alone. Writes are unchanged by '
25-
+ 'this entry: an insert or update that leaves the master reference empty is still refused with 422. '
26-
+ 'Declare `required: true` when every row must name its master; an omitted `required` keeps meaning '
31+
+ 'an object that carries no `owner_id` column, is read through View All alone. Writes follow the same '
32+
+ 'owner: an insert that names no master is admitted, and its creator owns the row; its owner and a '
33+
+ 'holder of Modify All on the detail update or delete a row whose master reference is empty, and '
34+
+ 'everyone else who reaches the write gate, View All included, is refused `403 PERMISSION_DENIED`; a '
35+
+ 'row with no owner, or on an object that carries no `owner_id` column, is written through Modify All '
36+
+ 'alone. An update that empties a named master asks edit access on the master it leaves. A required '
37+
+ 'master is unchanged: a row whose stored required master reference is empty is still refused with '
38+
+ '`422 MISSING_REQUIRED_FIELD`. Declare `required: true` when every row must name its master; an '
39+
+ 'omitted `required` keeps meaning '
2740
+ 'required through `ObjectSchema.create()` and on stored rows, and `os lint` reports it at `error` so the '
2841
+ 'author writes one of the two.',
2942
reason:

‎packages/spec/src/migrations/registry.ts.template‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,8 +1327,10 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
13271327
+ 'master from it, and a detail row that names no master is read by its owner and by View All on the '
13281328
+ 'detail, never through a master, while a row that names one is read through it as before. The builder '
13291329
+ 'keeps an explicit `required: false` as written and still forces an omitted one to `true`, and the '
1330-
+ 'stored-row listing stops naming the shape. Writes are unchanged: an empty master reference is still '
1331-
+ 'refused with 422. Its D3 record is the semantic entry `cbp-optional-master-admitted`.',
1330+
+ 'stored-row listing stops naming the shape. A detail row that names no master is written by its '
1331+
+ 'owner and by Modify All on the detail, and refused with 403 for everyone else; only an empty '
1332+
+ 'required master reference is still refused with 422. Its D3 record is the semantic entry '
1333+
+ '`cbp-optional-master-admitted`.',
13321334
},
13331335
{
13341336
id: 'chart-config-aria-retired',

0 commit comments

Comments
 (0)