Skip to content

Commit a5cb4f1

Browse files
docs(spec): the security-service contract describes an empty optional controlled_by_parent master reference (#31126)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiPNRknnohwjfU4LkaBEoT56)_ Fixes #31094 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:只改 `packages/spec` 里安全服务契约的注释(JSDoc):`master_chain` 拒绝、`master_reference_missing` 原因、`allow` 结果和 `checkControlledByParentWrite` 自身说明,现在都写明「可选主表为空」的行按属主规则判:属主或该对象「修改全部」可写,其他人在 `master_chain` 上被拒;链上某一跳的可选主表为空时,链在那一跳按同一规则结束。必填主表为空仍是原来的答案。 - **为什么改**:您对 #30905 的裁决「A」已由 PR #31082 落到写门,但契约注释还写着「主表一定必填」「写子记录一定要主记录编辑权」,读契约的人会得出错误结论。 - **风险与回滚**:纯注释加一个 patch changeset,类型、导出、运行时行为都不变;回滚即 revert。 - **先看**:`packages/spec/src/contracts/security-service.ts` 里 `ControlledByParentWriteDenialLeg` 的 `master_chain` 一条。 - **两分钟验证**:`git diff origin/main -- packages/spec/src/contracts/security-service.ts`,预期只有注释行变化。 - **需要你做的**:无,席位落地。 ## What changes JSDoc only, in `packages/spec/src/contracts/security-service.ts`, plus an `'@objectstack/spec': patch` changeset. Each sentence was checked against the write gate (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts`) and PR #31082's tests (`controlled-by-parent-optional-master.test.ts`), under ADR-0055 §3 as amended 2026-11-07 (maintainer ruling 「A」 on #30905 / #30906): 1. `ControlledByParentWriteDenialLeg`: the intro and `master_chain` now cover the record whose own optional master reference is empty (refused to a principal who neither owns it nor holds Modify All on its object; a row with no owner, or of an object without `owner_id`, is Modify All's alone), and a master above whose optional master is empty (the three legs run on it, then that row's owner rule ends the chain). Only a required master's empty reference above the record is the chain-walk refusal the old text described. 2. `ControlledByParentWriteUnresolvedReason`: `master_reference_missing` is a stored **required** master reference left empty; an empty optional one answers `allow` or `deny` on `master_chain`. 3. `ISecurityService.checkControlledByParentWrite`: "requires EDIT access to its master, or, where its optional master reference is empty, ownership of the record or Modify All on its object". Beyond the card's three items, the same file's sentences that the ruling also made false were corrected in place (same defect class, same file): the `allow` bullet of `ControlledByParentWriteOutcome`, the "answers the master check alone" sentence, and the empty-permission-set bullet (such a principal answers on `master_chain`, never `object_permission`, for a record whose optional master is empty). The ⛔ lines at the end of the member's docblock are untouched. ## Tests No behaviour change, so no test is added. The behaviour the text describes is pinned by `packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts` (`ISecurityService.checkControlledByParentWrite answers as the write path does`, including the required-master `master_reference_missing` control). ## Acceptance notes - No generated reference embeds this JSDoc: `content/docs/references/**` has no hit for these names, and `api-surface/contracts.json` / `export-origins/contracts.json` list the type names only, so nothing is regenerated. - Noted, not fixed here (pre-existing): the `allow` bullet does not mention the `sharing.controlledByParent.ownerMayEdit` opt-in leg (ADR-0055 §3 amendment of 2026-10-26), through which a by-id update by the row's owner with READ on the master also resolves `allow`. Handed to the seat for a separate card. - An independent full review of the diff found one should-fix and two nits, all applied; the delta review passed. Its verdict is posted as a comment on this PR. ## Verification Local gates on the final head are listed in the card's `os-dev-report`; CI runs the full set. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2etQ7BmrJuMddPx75W)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 962ea9d commit a5cb4f1

2 files changed

Lines changed: 59 additions & 19 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the security-service contract describes an empty optional `controlled_by_parent` master reference
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Documentation only; no type, export or behaviour changes. Since ADR-0055 §3 as amended on 2026-11-07, a `controlled_by_parent` object may have one optional master, and a row whose optional master reference is empty is written by its owner or by a holder of Modify All on its object. The JSDoc of `ISecurityService.checkControlledByParentWrite` and its outcome types now says so:
11+
12+
- `ControlledByParentWriteDenialLeg`'s `master_chain` also covers the record whose own optional master reference is empty, refused to a principal who neither owns it nor holds Modify All on its object, and a master above it whose optional master reference is empty, which ends the chain under that row's owner rule. Only a required master's empty reference above the record is the chain-walk refusal the old text described.
13+
- `ControlledByParentWriteUnresolvedReason`'s `master_reference_missing` is a stored **required** master reference left empty; an empty optional one answers `allow` or `deny` on `master_chain`.
14+
- `ControlledByParentWriteOutcome`'s `allow` and the member's own description name the owner rule beside edit access to the master.

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

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,12 @@ export interface OwnershipFloorAlternate {
328328
*
329329
* The check asks whether the caller may EDIT the record's master. It asks that
330330
* of each master up the `controlled_by_parent` chain until it reaches a master
331-
* that governs its own rows. Every value here is a refusal the check already
332-
* makes, `403 PERMISSION_DENIED`, and the check tells them apart by the reason
333-
* its refusal states:
331+
* that governs its own rows, or a master whose OPTIONAL master reference is
332+
* empty. A record whose own OPTIONAL master reference is empty names no master
333+
* to ask, so the check asks the record's owner rule instead (ADR-0055 §3 as
334+
* amended 2026-11-07). Every value here is a refusal the check already makes,
335+
* `403 PERMISSION_DENIED`, and the check tells them apart by the reason its
336+
* refusal states:
334337
*
335338
* - `object_permission`: the principal holds no object-level `update` grant on
336339
* a master.
@@ -340,12 +343,20 @@ export interface OwnershipFloorAlternate {
340343
* row, and no app-authored update policy on that master admits the row
341344
* ({@link ISecurityService.checkAuthoredRowWrite}).
342345
* - `master_chain`: the chain itself cannot be walked to a master that governs
343-
* its own rows. It re-enters an object it has already visited, it exceeds
344-
* the depth bound, or a master that is itself `controlled_by_parent` (the
345-
* record's own master, or any master above it) has no relation to derive its
346-
* own master from, is not present, or has an empty master reference. Past the
347-
* record itself the check answers these as refusals, not as defects of the
348-
* request, so they are a leg here and never `unresolvable`.
346+
* its own rows, or it ends at a row whose owner rule refuses the principal.
347+
* The record's own OPTIONAL master reference is empty and the principal
348+
* neither owns the record (its `owner_id`) nor holds Modify All on its
349+
* object; a record with no owner, or of an object without that column, is
350+
* owned by nobody here, so Modify All alone writes it. A master above the
351+
* record whose own OPTIONAL master reference is empty ends the chain under
352+
* that row's owner rule: the three legs above still run on it, and then the
353+
* principal must own it or hold Modify All on its object. The chain
354+
* re-enters an object it has already visited, or it exceeds the depth
355+
* bound. Or a master that is itself `controlled_by_parent`
356+
* (the record's own master, or any master above it) has no relation to
357+
* derive its own master from, is not present, or has an empty REQUIRED
358+
* master reference. Each of these is an access answer, not a defect of the
359+
* request, so it is a leg here and never `unresolvable`.
349360
*
350361
* The first three legs run on every hop, so `object_permission` can name the
351362
* master's own master. A closed vocabulary: a consumer branches on it and
@@ -374,8 +385,11 @@ export type ControlledByParentWriteDenialLeg =
374385
* is itself `controlled_by_parent` the walk reads that row to derive the next
375386
* master, so an absent row after the legs is the `master_chain` leg, on the
376387
* first hop or above it.
377-
* - `master_reference_missing`: the record's stored master reference is empty
378-
* (`422 MISSING_REQUIRED_FIELD`).
388+
* - `master_reference_missing`: the record's stored REQUIRED master reference
389+
* is empty (`422 MISSING_REQUIRED_FIELD`). An empty OPTIONAL master reference
390+
* is not this reason: the record then belongs to no master, and the check
391+
* answers `allow` for its owner or a holder of Modify All on its object, and
392+
* `deny` on the `master_chain` leg for anyone else.
379393
*/
380394
export type ControlledByParentWriteUnresolvedReason =
381395
| 'master_detail_relation_missing'
@@ -389,7 +403,10 @@ export type ControlledByParentWriteUnresolvedReason =
389403
*
390404
* - `allow`: the check does not refuse. Every leg passes on every master up the
391405
* chain for the principal (and, on an on-behalf-of context, for the delegator
392-
* too), or the context is a system one, which the write path never checks.
406+
* too), and where the chain ends at a row whose OPTIONAL master reference is
407+
* 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.
393410
* - `deny`: the check refuses, on the {@link ControlledByParentWriteDenialLeg}
394411
* it names.
395412
* - `not_applicable`: the object does not declare `controlled_by_parent`, so
@@ -1062,10 +1079,14 @@ export interface ISecurityService {
10621079
* `recordId` on `object` by `context`.
10631080
*
10641081
* A by-id write to a `controlled_by_parent` record requires EDIT access to
1065-
* its master. The write path checks it on every master up the chain: the
1066-
* object-level `update` grant, the master's write row-level security, and
1067-
* record sharing (whose refusal an app-authored update policy on the master
1068-
* can lift, {@link checkAuthoredRowWrite}). This member exposes that check
1082+
* its master, or, where its optional master reference is empty, ownership of
1083+
* the record or Modify All on its object (ADR-0055 §3 as amended
1084+
* 2026-11-07). The write path checks edit access on every master up the
1085+
* chain: the object-level `update` grant, the master's write row-level
1086+
* security, and record sharing (whose refusal an app-authored update policy
1087+
* on the master can lift, {@link checkAuthoredRowWrite}). A master whose own
1088+
* 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
10691090
* to code outside the write path, such as a gate on a record's attachments or
10701091
* comments that must judge the record as its own update is judged. It adds no
10711092
* verdict: every outcome it resolves with is one the check already reaches,
@@ -1080,13 +1101,18 @@ export interface ISecurityService {
10801101
* refuses on `deny` refuses exactly the principals this check refuses on the
10811102
* record's own update. It answers the master check alone: the gates the
10821103
* update meets on the record itself (its object-level grant, its own
1083-
* row-level security and sharing) are not part of the answer.
1104+
* row-level security and sharing) are not part of the answer. The owner rule
1105+
* of a record whose optional master reference is empty is the master check's
1106+
* own answer, and is part of it.
10841107
*
10851108
* The contexts around the check answer as the write path does:
10861109
* - A system context bypasses the write path, and answers `allow`.
10871110
* - A principal whose permission sets resolve to none is judged over that
1088-
* empty list. It holds no `update` grant on any master, so it answers `deny`
1089-
* on `object_permission` (ADR-0056 D2).
1111+
* empty list. It holds no `update` grant on any master, so wherever a master
1112+
* is asked it answers `deny` on `object_permission` (ADR-0056 D2). A record
1113+
* whose optional master reference is empty asks no master: such a principal
1114+
* holds no Modify All either, so it answers `allow` only as the record's
1115+
* owner, and `deny` on `master_chain` otherwise.
10901116
* - Where the write path refuses the CONTEXT before any gate runs, the member
10911117
* rejects with that same refusal, `403 PERMISSION_DENIED`. Those cases are a
10921118
* context with no principal at all (ADR-0096 D5), permission sets that

0 commit comments

Comments
 (0)