Skip to content

Commit eac28c3

Browse files
docs(adr): ADR-0055's Lint bullet gains a dated note stating the narrowed ownerMayEdit lint arm (#26862)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi92oviX8Kae6u5irtoKdwade)_ Fixes #26686 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:ADR-0055 的 2026-10-26 修正案里 **Lint** 那一条仍写着「任何按调用者键的 update 策略都被提示加 `ownerMayEdit` 开关」。本 PR 不改那句话,只在它正下方追加一条带日期的注记(并在 Status 行同步加一条 Amended 记录),说明 #26591 / PR #26658 之后 lint 的真实行为:只有按 `owner_id` 键、且明细表确有 `owner_id` 列、尚未开启开关的策略才被建议加开关;按别的人员字段键的策略(或 `owner_id` 列不存在)无论开没开开关都会被提示,补救是「把负责人放进 `owner_id`」,从不建议开关。 - **为什么改**:ADR 是写 master-detail 应用的 agent 最先读的地方;旧句子已让 hotclm 为 `owner` 字段策略声明了开关,lint 安静了,负责人照样 403。#26591 修了 lint,ADR 还在描述旧的臂。 - **风险与回滚**:纯文档,追加式(Prime Directive 13),不动门禁、spec、lint;回滚 = revert 本 PR。 - **先看**:`docs/adr/0055-master-detail-controlled-by-parent.md` 第 178 行(注记)与第 7 行(Status 行新条目);两处皆为新增行,原文零改动。 - **两分钟验证**:`git diff origin/main -- docs/adr/0055-master-detail-controlled-by-parent.md`,预期只有 2 行新增、0 行删除;`pnpm check:adr-anchors && pnpm check:adr-symbol-anchors` 预期绿。 - **需要你做的**:批准本 PR(受管面 `docs/adr/**`,Tier H,席位不转 ready、不入队)。 ## What changes One file, two inserted lines, nothing rewritten (ADRs are append-only, Prime Directive 13; the card's "reword that one bullet" yields to it, as the triage audit line and the retriage answer on #26686 say): - `docs/adr/0055-master-detail-controlled-by-parent.md`, the **Lint** bullet of "What the opt-in does, exactly" (the 2026-10-26 amendment for #25760) gains a dated note directly under it, in the file's own in-place form (`⚠️ **Amended YYYY-MM-DD (#N) — …**`, the shape the 2026-10-22 and 2026-09-07 notes use). The note states the arm `main` carries since #26591 / PR #26658, read off `packages/lint/src/validate-security-posture.ts` on `origin/main`: - the opt-in is prescribed only for an `update` / `all` policy that keys the caller on `owner_id`, on a `controlled_by_parent` detail that carries `owner_id` and has not opted in (`pushCbpOwnerPolicyWithoutOptIn`); - every other caller-keyed update policy (another user field such as `owner == current_user.id`, or `owner_id` on a detail with no such column) is flagged in both states, opted in or not, with the `owner_id` remedy and never the opt-in (`pushCbpCallerPolicyOffOwnerColumn`); - the rule id and the `warning` tier are unchanged; the rule's `os explain` entry says the same; - the bullet above it is kept as the arm as first landed and marked as no longer a statement of what the lint prescribes; - the open question (widening the runtime leg to a named owner field) is named as the maintainer's on #26591 and not decided. - The **Status** line gains a matching `· **Amended** (2026-10-27, #26591 / PR #26658) — …` entry, because every in-place note in this file is paired with one there. No gate, spec, lint or other ADR text changes. No changeset: `docs/adr/**` publishes nothing (`skip-changeset` sent with the create). ## Tests None owed: a docs-only diff under `docs/adr/`, no package touched, no tested behaviour changed, so no ablation. ## Acceptance notes - Acceptance read against bullet + note together: the pair no longer tells a reader that every caller-keyed policy gets the opt-in; `owner_id` is named as the one column the opt-in admits; `check:adr-anchors` is green at the head and ADR-0055 declares the same machine-readable decision-letter count at base and head (0 headings, 0 bold leads, 0 table cells on both `origin/main` 8eef9a8 and the head, by the four grammars `check-adr-anchors.mjs` reads). - Premise re-verified on `origin/main` 8eef9a8 (the delta's 0ea90c7 has moved on): the Lint bullet is unchanged at line 176, and no open PR names `docs/adr/0055*`. - Symbol anchors in the note are the `path#symbol` form `check:adr-symbol-anchors` resolves (`pushCbpOwnerPolicyWithoutOptIn`, `pushCbpCallerPolicyOffOwnerColumn`, `SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION` all exist on `origin/main`). - `docs/adr/README.md` is generated and did not need regenerating: its status column truncates ADR-0055's value before the new entry (`check:adr-index --report` reads it current). - Forced test edits: none. ## Verification Local, in the worktree at head `a7fbcca4` (the only commit), after it: - Build closure (so the lint-owned gate below could load `@objectstack/lint` / `@objectstack/formula`): `pnpm turbo run build --filter='@objectstack/lint...' --concurrency=2` under `scripts/pm/os-verify-lock.sh`: `VERDICT command-exit 0 · held the lock 102s`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (change set derived from git, 1 path vs merge base `8eef9a8d`) listed 21 commands; every one ran serially with stdin closed and recorded exit 0: `check-adr-links` (+ `--self-test`), `check-closing-keyword-parity` (+ `--self-test`), `check-comment-mask-corpus`, `ci/plan-test-core-shards --self-test`, `@objectstack/lint check:doc-formula-expressions`, `check:adr-anchors`, `check:adr-index`, `check:adr-symbol-anchors`, `check:ci-filter-parity`, `check:closed-ceremony-text`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-prior-rulings`, `check:refd-timer-probe`, `check:watch-hint-literal`. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt`: `✓ dispatch-gates --ran: 21 derived famil(ies) accounted for — 21 run, 0 NOT-MEASURED (a DERIVED zero — all 21 recorded an exit code and none of them is 3)`. - `dispatch-gates --tier`: MANDATORY `docs/adr/**` (clause ①, no one-line exit); changed lines 2 (+2 / -0), under the 3000 human-merge threshold. - `check:adr-symbol-anchors`: `2690 anchors across 144 records resolve`, the three new `path#symbol` anchors among them. - Every CI family is the PR's own check-runs, read there, not re-run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPxbS3CePBs6PrwdLc)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc92c62 commit eac28c3

1 file changed

Lines changed: 2 additions & 0 deletions

File tree

‎docs/adr/0055-master-detail-controlled-by-parent.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
· **Revisited in part** (2026-10-10, [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md), Proposed) — §2's alternatives row (a) is reversed for ONE engine-internal, non-authorable leaf the engine composes from the compiler's output; the RLS compiler still has no subquery form, and §2's chosen mechanism (b), its `rlsMembership` IN-form and the set-size limit all stand.
55
· **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).
66
· **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.
7+
· **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".
78
**Deciders**: ObjectStack Protocol Architects
89
**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)
910
**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).
@@ -174,6 +175,7 @@ An ADR recording a **narrower** enforcement than the runtime performs errs in th
174175
- **Gate.** `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite` gains one leg, before the §3 chain walk, for a **by-id `update`** only. It admits the write when **all** hold: the detail's resolved relation carries the opt-in **and** the registered schema carries the `owner_id` column (`CbpRelation.ownerMayEdit`, resolved beside the relation in `security-plugin-cbp-relation.ts`); the write is **not** on behalf of another user; the stored row's `owner_id` is the caller's own `userId`; and the caller can **read** the master row, asked of the read door itself under the caller's context — the master's object grant, its row-level and sharing scope and, for a master that is itself `controlled_by_parent`, the chain the engine composes (ADR-0139 D6). A hidden row and a refused read are one answer, "not readable" (#7505). When any condition fails, the write takes the §3 walk and carries the walk's own refusal; the leg adds no envelope of its own.
175176
- **What the leg does not change.** Gate ① (the detail's own object-level `update` grant) and step 2.7 (the detail's own row-level policies, where `owner_id == current_user.id` now decides as authored) run before it, unchanged. A body that re-points the master reference still asks **edit** on the master it names: the owner may change their row, not move it under a master they cannot edit. The on-behalf-of path keeps both floors, as ADR-0090 D10 keeps them for every other relief.
176177
- **Lint.** `@objectstack/lint` gains `security-controlled-by-parent-owner-policy-without-opt-in` (`warning`): an `update` / `all` policy that keys a field on `current_user.id`, on a `controlled_by_parent` detail of the stack that has **not** opted in, is flagged with the opt-in as the fix. Advisory, because a narrowing can be meant and no runtime door refuses the shape.
178+
⚠️ **Amended 2026-10-27 (#26591 / PR #26658) — the opt-in is prescribed only where the gate honours it.** The leg above admits one caller, the user in the stored row's `owner_id`, and only on a detail whose registered schema carries that column (`CbpRelation.ownerMayEdit` resolves `false` otherwise), so the arm no longer prescribes the opt-in for every caller-keyed policy. Under the same rule id and the same `warning` tier, `packages/lint/src/validate-security-posture.ts#pushCbpOwnerPolicyWithoutOptIn` prescribes the opt-in only for an `update` / `all` policy that keys the caller on `owner_id`, on a `controlled_by_parent` detail that carries `owner_id` and has **not** opted in — the one shape the opt-in turns into an admission. Every other caller-keyed update policy — one keyed only on another user field (`owner == current_user.id`), or on `owner_id` where the detail carries no such column (`ownership: 'business_unit' | 'org' | 'none'`, or a platform-managed object) — goes to `packages/lint/src/validate-security-posture.ts#pushCbpCallerPolicyOffOwnerColumn` and is flagged in **both** states, opted in or not: the verdict says the opt-in admits only the stored `owner_id`, the remedy is to key the accountable person on `owner_id` (or to give the detail that column), and the opt-in is never named as the fix. The rule's `os explain` entry (`packages/lint/src/rule-explanations.ts#SECURITY_CBP_OWNER_POLICY_WITHOUT_OPT_IN_EXPLANATION`) says the same. The sentence above is kept as the arm as first landed; ⛔ it is no longer a statement of what the lint prescribes. Whether the runtime leg should widen to a named owner field is the maintainer's open question on #26591; this note records it and decides nothing.
177179

178180
### What is out of scope, and why
179181

0 commit comments

Comments
 (0)