Skip to content

Commit 6106b73

Browse files
feat(spec,security): a controlled_by_parent object may declare an optional master (#30940)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DC6kPDNUbz8zYdVCQqG8k42)_ domain:server#1 · os-elon-musk · session_016WV4cxZsyeZedvM4Ea8Ahs Fixes #30905 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — #30907 (card 3/3 of the ruling: the docs and skills that teach `controlled_by_parent` still say the master is always required); this diff teaches the optional master in ADR-0055 §1 and §2 as amended, in `os explain relationship/master-detail-required` and `os explain security-controlled-by-parent-no-relation`, and in the semantic migration entry `cbp-optional-master-admitted` Claim: 6685806956 (the seat's latest full re-post of 6682716818: this branch, the amended file surface, and five `ui/` test files as a surface conditional on ruling A; supersedes 6684936456, 6683296158 and 6682753060) · Ruling: A, record 6687830530 · Branch: `claude/project-thread-prdvgm` ## 维护者速读 - 改了什么:`controlled_by_parent` 的子对象现在可以把它唯一的 `master_detail` 字段写成 `required: false`(可选主表)。挂了主记录的行照旧由主记录决定谁能读;主表引用为空的行,只有它的属主(`owner_id`)和对该子对象有「查看全部」的人能读。另含 lint 规则、explain 叙述、ADR-0055 §1/§2 修订、升级清单的相应调整,以及 ui 里 5 个测试按裁决 A 改的对照断言(只改测试,Studio 源码不动)。 - 为什么改:「销售看自己客户下的联系人;联系人可以不挂客户」以前没有声明式写法,只能复制一个属主字段再用钩子同步。这是您对 #30905 的裁决 A(三张卡的第 1 张)。 - 风险与回滚:读侧只在唯一的读收口 `computeControlledByParentFilter` 里多了一个 `$or` 分支,仍在租户墙之下;必填主表的过滤条件逐字节不变。写侧不变:主表引用为空的写入仍然 422(第 2 张卡 #30906 才改写闸)。回滚即撤 PR,没有数据迁移。 - 先看:`packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts` 的新分支(约 30 行),再看新测试 `controlled-by-parent-optional-master.test.ts` 的读侧几条规则。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/controlled-by-parent-optional-master.test.ts`,预期 24/24 通过;把 owner 分支删掉再跑会红 6 条(见下文消融)。 - 需要你做的:A/B/C 已裁 A(记录 6687830530),本 PR 按 A 实现,ui 的 5 个测试已随之改好。剩下的合同复审和 ADR 的 Tier H 批准由席位安排;Studio 不显示可选主表这个缺口,席位另开 ui 卡。 ## What changes **Spec (`@objectstack/spec`, `minor`).** A `controlled_by_parent` object whose only `master_detail` is declared `required: false`, and which declares no required one, now has an **optional master**. - `controlledByParentMasterOf(fields)` is new in `packages/spec/src/data/controlled-by-parent-master.ts`, exported from `@objectstack/spec/data`: it answers the master field and whether it is optional (one required `master_detail` ⇒ that, not optional; more than one required ⇒ none; otherwise exactly one `master_detail` declared `required: false` ⇒ that, optional). `controlledByParentMasterRefusal` (the parse's one-master rule) judges through it. `isControlledByParentMasterField` still means the REQUIRED master. - `ObjectSchema.create()` (`forceCbpMasterDetailRequired` in `object.zod.ts`): an explicit `required: false` on the object's ONLY `master_detail` is kept as written; beside another `master_detail` it is still refused, with a message saying why. The force half (omitted `required` ⇒ `true`) is unchanged, and so is the D2 conversion `cbp-master-detail-required-defaulted` (its test file only gains rows that pin it). - Every other refusal stands: two masters, a required `lookup` only, a `readonly` / `system` master (required or optional). - `conversions/refused-shapes.ts`: the `master-not-required` arm no longer lists an explicit `required: false` sole master. A D3 semantic entry, `migrations/entries/semantic/18.cbp-optional-master-admitted.ts` (registered in `registry.ts.template`, order 119), records the widening. - `readable-ids-conformance.ts` gains a case for the leaf shape this produces: a leaf whose scope ORs a nested leaf, OR-ed with an empty-reference arm. - `api-surface/data.json` and `export-origins/data.json` regenerated (`controlledByParentMasterOf`). **Read (`@objectstack/plugin-security`, `minor`).** `computeControlledByParentFilter` (`security-plugin-cbp-read-filter.ts`) is still the one read chokepoint. For a required master it returns the leaf it always did. For an optional master it returns ``` { $or: [ { [fk]: { $in: ReadableIds.of(master) } }, nullMasterArm ] } ``` where `nullMasterArm` is `{ [fk]: null }` for a holder of View All on the detail (`permissionEvaluator.hasSuperuserReadBypass(object, permissionSets)`), else `{ $and: [ { [fk]: null }, { owner_id: caller } ] }` when the detail carries `owner_id`, else no arm (master arm only). The `$or` is this layer's answer, AND-ed by every caller with the tenant wall and the detail's own layers as before. - The relation resolver (`security-plugin-cbp-relation.ts`) picks the optional master through `controlledByParentMasterOf` when there is no required candidate, and `CbpRelation` carries `optional` and `ownerColumn` (`security-plugin.ts`). `reportCbpMasterUnresolved`'s advice names both spellings. - The five call sites pass the permission sets they already resolved: the authz middleware for the caller and, on a delegated request, for the delegator; `getReadFilter`; and the delegated read filter for the delegate and the delegator. So View All is read for the principal each answer is for. - `explain-engine.ts`: `security/explain` on a record read names the null-master arm (which arm returned the row; for a withheld row with an empty reference, who reads it instead, and that a queue row is View All's alone). **Lint (`@objectstack/lint`, `minor`).** `relationship/master-detail-required` passes the optional master, still reports `readonly` / `system` on it, and reports an omitted `required` on the only `master_detail` at `error` naming both spellings. `validate-security-posture.ts` resolves the optional master as the parse does, so `security-controlled-by-parent-no-relation` no longer calls a valid optional master "no relation". `rule-explanations.ts` teaches it. **ObjectQL (`@objectstack/objectql`, `minor`).** `engine-readable-ids-permission-formula.ts` resolves the master through `controlledByParentMasterOf`, so the boot refusal of a master the engine cannot compose a read over judges an optional master's detail too. **Record (Tier H).** `docs/adr/0055-master-detail-controlled-by-parent.md`: status line, inline notes at §1 and §2, and a new section "Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads". **Ledger.** `scripts/engine-double-contract.pinned.json` records the new suite's one pinned `findOne` double (`check-engine-double-contract --write`, no seam lost). ## Why Card #30905, maintainer ruling A: "a rep sees the contacts of the accounts they own; a contact need not have an account" had no declarative form. `controlled_by_parent` refused a `master_detail` that is not required at the builder and at the parse, a `lookup` derives no access, and the only route was a copied owner field kept in step by hooks. Premise re-read on `main`: both refusals are at the lines the card cites, `object.zod.ts` 4406-4417 (the builder's explicit-false throw) and 2743-2752 (the parse refinement), no drift. One gap the card did not name is the open question below. ## Tests New suite `packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts` (24 tests, both read faces: the data middleware and `getReadFilter`): - the injected leaf shape (master arm unchanged, OR-ed with `fk IS NULL AND owner_id = caller`), and a required master's leaf unchanged (no `$or`); - the rep reads the rows of masters they read and the null-master rows they own, nothing else; - a row that NAMES a master is never read through the owner arm (the rep owns a contact under an account they cannot read); - a caller who reads another master does not see the null-master rows they do not own; - a null-master row with no owner (an ADR-0142 queue row) is not read through the owner arm; - View All on the detail reads every null-master row, the queue row included, and does NOT open the master arm; - an object with no `owner_id` column: a null-master row is View All's alone; - chain: the children of an intermediate row with no master are read by that row's owner, exactly those; - the answer is the disjoint union of the two arms; - the write gate is unchanged: an empty master reference on insert is still 422 `MISSING_REQUIRED_FIELD`; an insert naming a readable master reaches the executor; - the resolver resolves the optional master (no "resolves no master relation" report), and the parse accepts the detail whole; - explain: a returned null-master row names the empty reference and the owner arm; a withheld one names who reads it, and a queue row is View All's alone; a row that names its master, and every row of a required master, keep the master-scope narration. Updated pins: spec (`controlled-by-parent-master.test.ts`, `object.test.ts`, `cbp-master-detail-required-defaulted.test.ts`, `refused-shapes.test.ts`, `stack-cbp-extension-master.test.ts` (an extension making the one master optional passes like its declared control; beside a second master it is refused), and `shared/retired-key-removal-version.pin.test.ts` (the refusal the parse still answers on the defaulted conversion's surface is now two `master_detail` fields with no `required`)), lint (`data-model-rules.master-detail-required.test.ts`, `validate-security-posture.test.ts`, `validate-security-posture-extension-merge.test.ts`), `controlled-by-parent-sharing.test.ts` (the retired-tier table's omitted row now parses, pinned as the parse/builder divergence below), `packages/metadata-protocol/src/protocol.stored-migration.test.ts` (the stored `required: false` row is counted canonical, no longer listed), and five `ui/packages/app-shell` tests that used the spec as an oracle (below, under Verification). The readable-ids conformance suites passed on the memory, sql, sqlite-wasm, mongodb and turso drivers; the new case was confirmed by name on memory, sql and turso (local and remote). **Ablation** (`node scripts/ablation-replace.mjs`, each restore verified blob equal to HEAD, `2ba0466291de` for the read filter): - (a) the null-master arm dropped (`nullMasterArm` replaced with `null`): **6 red** — the injected-shape pin, "the rep reads ... the null-master rows they own" on both faces, the chain case on both faces, and the disjoint-union case. - (b) the owner arm unconfined (`{ owner_id: caller }` without the `fk IS NULL` conjunct): **8 red** — the six above plus "a row that NAMES a master is never read through the owner arm" on both faces. ## Acceptance notes - **A parse cannot tell an omitted `required` from `required: false`** (the parsed field map defaults it to `false`). So under option A a raw parse, and `defineStack` over a plain object literal (it returns the parsed stack), admits an omitted `required` on the only `master_detail` as the optional master. The builder still forces it to `true`, the D2 conversion still converts a stored row, and `os lint` reports it at `error`. The runtime registry reads `required === false`, so an object that reaches it unparsed with `required` omitted still resolves no master and every read is denied. Pinned in `controlled-by-parent-master.test.ts`, `object.test.ts`, `validate-security-posture.test.ts` and `controlled-by-parent-sharing.test.ts`. - An extension that redeclares the master `required: false` makes it the optional master (`validate-security-posture-extension-merge.test.ts`). - A cycle or depth refusal of the chain still denies the whole object, null-master rows included. - The file surface grew beyond the first claim by the files the seat accepted in 6683296158: `validate-security-posture.ts`, `rule-explanations.ts`, `engine-readable-ids-permission-formula.ts`, a docblock in `engine.ts`, and the plumbing in `security-plugin.ts`, `security-plugin-authz-middleware.ts`, `security-plugin-read-filter.ts`. Declared to the seat after that: `packages/metadata-protocol/src/protocol.stored-migration.test.ts` (a test pin this change moves) and one docblock in `packages/objectql/src/engine-top-level.ts` (`UnservableControlledByParentMaster.field`, which the change made false). Generated or ledger files: `api-surface/data.json`, `export-origins/data.json`, `scripts/engine-double-contract.pinned.json`. On ruling A, the conditional surface of claim 6685806956: five `ui/packages/app-shell` test files, test-only. - `forceCbpMasterDetailRequired` is on the claim's ⛔ list for its force half; this diff changes only its explicit-false throw (the `master-not-required` refusal at `ObjectSchema.create()` the file surface names), narrowed to the object's only `master_detail` as the seat agreed. The force half and the D2 conversion source are unchanged. - Out of scope and untouched: the write gate (#30906), the docs and skills (#30907), `ui/**` beyond those five test files (Studio source included), `.claude/**`. - Studio still writes `required: true` on a cbp master and its OWD overview names a master only for `required: true` (`owd-sharing.ts` `deriveMasterObject`), so it shows none for an optional master. The seat cards that display gap separately; this PR does not change it. ## Ruling (maintainer, decision card) `FieldSchema.required` is `z.boolean().default(false)` and the parse's cbp refinement reads the PARSED field map, so the parse cannot tell an explicit `required: false` from an omitted one. The card's "accept explicit false at the parse" and "omitted still means required" could not both hold at the doors that parse without the builder force or the D2 conversion (`defineStack` over a plain object literal, `os validate` / `os build` on such a config, the metadata save door). Three options went to the maintainer; **A was ruled** (record 6687830530): - **A** (implemented): the parse accepts the one `master_detail` that is not `required: true`, so at those raw doors an omitted `required` is an optional master, not a refusal. Omitted stays required where the omission is visible: `ObjectSchema.create()` (force unchanged), stored rows (D2 unchanged), Studio (writes `required: true`). `os lint` reports the omission at `error`. The runtime reads `required === false`, so an object that reaches it unparsed with no `required` key still resolves no master (fail closed). Consequence: a `defineStack` plain literal that omits `required` now passes and runs as an optional master. - B (not chosen): refuse the omission at every parse door through a new raw-input mechanism on `ObjectSchema`. - C (not chosen): a separate explicit opt-in key instead of `required: false`. ## Verification Head `1899fc5e98`: the branch's four earlier commits (on `main` `98f640d5`), a plain merge of `main` `1b3e22f0` (`fd6d188b`, one file in common, `packages/spec/src/data/object.zod.ts`, merged cleanly), the ui test commit `469499edc7`, the contract-review fix commit `fa5c3fee34` (the ADR-0055 invariant in five `scripts/adr-anchors` files moved to §1 as amended; ADR-0055's amendment cites ruling record 6687830530 instead of an open question), a plain merge of `main` `000a4c0d` (`c6541998`, no file in common), and `1899fc5e98`, test-only: module-top `import '@objectstack/lint'` in `protocol.stored-migration.test.ts`, and `import '@objectstack/lint'` + `import '@objectstack/formula'` in `ObjectFieldInspector.cbpRequiredMaster-12326.test.tsx`, for the test-source alias gate's source-reach half (the sources keep their dynamic imports). The tables below were measured on `2374556f91` / `835c94d1` after a whole-tree `turbo run build --filter=!@objectstack/docs` (118/118 tasks, lock VERDICT command-exit 0); the delta since is listed after them. Each run under the verify lock, VERDICT command-exit 0 for the lock, exit code per command recorded: | Package | Command | Result | |---|---|---| | spec, lint, objectql, plugin-security | `pnpm --filter @objectstack/NAME typecheck` (tsc plus `check:test-typecheck`) | exit 0 each | | `@objectstack/spec` | `test` (local project) | 817 files passed, 1 skipped; 25856 tests passed, 1 skipped, 1 todo (the skip is `scripts/root-entry-type-nameability.pin.test.ts`'s dist-freshness gate: spec test files changed after the build) | | `@objectstack/lint` | `test` | 196 / 196 files; 8288 / 8288 tests | | `@objectstack/plugin-security` | `test` | 247 / 247 files; 5710 passed, 45 skipped | | `@objectstack/objectql` | `test` (local project) | 558 / 558 files; 11160 / 11160 tests | | `@objectstack/metadata-protocol` | `test` | 320 files passed, 4 skipped; 22487 passed, 26 skipped | | `@objectstack/verify` | `test` | 48 / 48 files; 408 / 408 tests | | `@objectstack/plugin-sharing` | `test` | 67 / 67 files; 1436 / 1436 tests | | `@objectstack/service-analytics` | `test` | 211 / 211 files; 5015 passed, 300 skipped | | cli, plugin-audit, service-storage, rest | the 13 test files there that name `controlled_by_parent` | 13 / 13 files; 347 / 347 tests | The spec, lint, plugin-security and objectql full runs were taken on `835c94d1` (the source as here; the commit after it changes three test files and one docblock); the spec suite was then re-run whole on `2374556f91` (above), as were the rest. **Test UI under ruling A.** On `2374556f91`, Test UI was red in shards 1, 2, 7 and 16: six cases in five `ui/packages/app-shell` test files that used the spec's `ObjectSchema` as an oracle for "a `controlled_by_parent` object whose only `master_detail` omits `required` is refused". Under A the parse admits it as the optional master. Re-pointed test-only in `469499edc7` (Studio source unchanged): - four control legs now assert that shape parses with `required: false` before Studio writes `required: true`, and with `required: true` after: `metadata-admin/previews/object-fields-io.test.ts`, `metadata-admin/inspectors/ObjectFieldInspector.cbpRequiredMaster-12326.test.tsx`, `studio-design/ObjectSettingsPanel.cbpRequiredMaster-12326.test.tsx`, and the save case of `studio-design/PackageOwdOverviewPanel.cbpRequiredMaster-12326.test.tsx`; - two display-agreement rows ("one master without required", in `studio-design/owd-sharing.test.ts` and in the shapes table of `PackageOwdOverviewPanel.cbpRequiredMaster-12326.test.tsx`) now record that the parse admits the shape while Studio names no master, as the runtime does for an unparsed object. Run from `ui/` on this head: `pnpm exec vitest run` on the five files, 5 / 5 files, 77 / 77 tests; `tsc -p tsconfig.test.json --noEmit` in `ui/packages/app-shell`, lock VERDICT command-exit 0, 0 errors. The whole Test UI matrix is read from this PR's check runs. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived the families; each ran sequentially with its exit code recorded before any pipe. Four needed a second run: `check:dual-build-cjs-loads` and `check:i18n` exit 3 (no dist) before the whole-tree build and exit 0 after; `check-plugin-teardown-shape --self-test` exit 3 (shallow clone, pinned fixture commit unreadable) and exit 0 after fetching that commit; `check:engine-double-contract` exit 1 (the new suite's double not in the ledger) and exit 0 after `--write`. `pnpm lint`: exit 0. - `dispatch-gates --ran` on `2374556f91`: "108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED". - Delta on `469499edc7`: the derivation grew to 112 families (`check-sdui-manifest` and its self-test, `pnpm --dir ui exec vitest run scripts/`, `check:stack-collection-maps`), all exit 0; every `@objectstack/spec` `check:*` family, `check:doc-formula-expressions`, `check:engine-double-contract`, `check:nul-bytes` and `check:test-source-alias` were re-run after the merge. Five spec checks exit 3 and `check:generated` exit 1 against the pre-merge `dist`; after `turbo run build --filter=@objectstack/spec` all six exit 0 and no artifact changed. `pnpm lint` exit 0. `dispatch-gates --ran`: "112 derived famil(ies) accounted for — 112 run, 0 NOT-MEASURED". - Delta on `fa5c3fee34` (six files: five anchors, the ADR): `dispatch-gates --commands` on those paths derives 29 families, all exit 0 (`check:adr-anchors`, `check:adr-links` and its self-test, `check:adr-index`, `check:adr-symbol-anchors` among them); `--ran`: "29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED". `pnpm lint` exit 0. - Delta on `1899fc5e98` (after the `000a4c0d` merge, a spec rebuild and `pnpm install` for main's `playwright-core`): `pnpm check:test-source-alias` exit 0 (it was exit 1 on the two files before); `protocol.stored-migration.test.ts` 47 / 47; from `ui/`, `ObjectFieldInspector.cbpRequiredMaster-12326.test.tsx` 5 / 5. `dispatch-gates --commands` on the two files derives 54 families, all exit 0 (`check:type-check-debt` exit 3 first, `@objectstack/scenarios` unbuilt, exit 0 after building it); `--ran`: "54 derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED". `pnpm lint` exit 0. - After the merge, targeted re-runs: spec (`object.test.ts`, `controlled-by-parent-master.test.ts`, `conversions/`, `stack-cbp-extension-master.test.ts`, the retired-key pin, `field.test.ts`) 27 / 27 files, 1415 / 1415 tests; plugin-security optional-master and sharing suites 2 / 2 files, 63 / 63 tests. - Every family CI runs on this PR is read from the PR's check runs, not re-run here. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`controlledByParentMasterOf` is a pure reading of a field map, beside the existing refusal.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The `维护者速读` section is the maintainer's Chinese summary the fleet asks for.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxZsyeZedvM4Ea8Ahs)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0306761 commit 6106b73

45 files changed

Lines changed: 1666 additions & 236 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/objectql': minor
6+
---
7+
8+
feat(spec, security): a `controlled_by_parent` object may declare an optional master, and a detail row with no master is read by its owner
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: added
12+
Skills-docs: follow-up owed — #30907 (card 3/3 of the ruling: the docs and skills that teach `controlled_by_parent` still say the master is always required); this diff teaches the optional master in ADR-0055 §1 and §2 as amended, in `os explain relationship/master-detail-required` and `os explain security-controlled-by-parent-no-relation`, and in the semantic migration entry `cbp-optional-master-admitted`
13+
14+
A rule like "a rep sees the contacts of the accounts they own; a contact need not have an account" now has a declarative form. Declare the detail's only `master_detail` with `required: false`:
15+
16+
```ts
17+
fields: {
18+
account: Field.masterDetail('crm_account', { label: 'Account', required: false }),
19+
},
20+
sharingModel: 'controlled_by_parent',
21+
```
22+
23+
- **`@objectstack/spec`: the optional master.** A `controlled_by_parent` object whose only `master_detail` is declared `required: false` passes the parse and `ObjectSchema.create()` with that value kept. `controlledByParentMasterOf(fields)` is exported from `@objectstack/spec/data` beside `controlledByParentMasterRefusal`: it answers the one master's field and whether it is optional. `isControlledByParentMasterField` still means the required master. Every other refusal stands: two masters, a required `lookup` only, and a `readonly` or `system` master. `ObjectSchema.create()` still forces an omitted `required` to `true`, and still refuses `required: false` on a `master_detail` beside another one. A stored row the v18 upgrade listed because its master declared `required: false` is valid now and no longer listed (`os migrate meta`); the semantic entry `cbp-optional-master-admitted` records it. The raw parse reads an omitted `required` as `false` and so admits it as the optional master; `os lint` reports the omission (below).
24+
- **`@objectstack/plugin-security`: the null-master read arm.** For an optional master, the controlled-by-parent read filter is the unchanged master arm OR a second arm for rows whose master reference is empty: every such row for a holder of View All on the detail, else the caller's own (`owner_id`), else none (an object without an `owner_id` column). A row with no owner (a queue row) is View All's alone. The two arms never share a row, and View All on the detail does not open the master arm. Every read door composes the one filter, so the data API, analytics, search, export, the parent gates of attachments, comments, activity and approvals, chains and delegated reads follow. A required master's filter is unchanged. `security/explain` names the null-master arm on a record read. Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422.
25+
- **`@objectstack/lint`.** `relationship/master-detail-required` passes the optional master, still reports `readonly` / `system` on it, and reports an omitted `required` on the only `master_detail` at `error`, naming both spellings (`required: true`, or `required: false` for an optional master). `security-controlled-by-parent-no-relation` and `-ambiguous-relation` resolve the optional master as the parse does.
26+
- **`@objectstack/objectql`.** The boot refusal of a master the engine cannot compose a read over now judges an optional master's detail too.

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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.
77
· **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".
8+
· **Amended** (2026-11-06, #30905, maintainer ruling 「A」) — **§1 admits an optional master and §2 gains a null-master arm**: a `controlled_by_parent` object whose only `master_detail` is declared `required: false` derives an OPTIONAL master. A detail row that names its master is read through it, unchanged; a row whose master reference is empty is read by its owner (`owner_id`) and by View All on the detail, through a second arm OR-ed with the master arm inside the one read chokepoint, `computeControlledByParentFilter`. §3 (the write gate) is unchanged by this note: a write that leaves the reference empty is still refused. See **"Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads"** at the end.
89
**Deciders**: ObjectStack Protocol Architects
910
**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)
1011
**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).
@@ -36,6 +37,7 @@ A **master-detail detail** record today is access-controlled entirely on its own
3637

3738
- Add `controlled_by_parent` to the **authorable** `object.sharingModel` enum (converging it with `OWDModel`; resolves the inconsistency).
3839
- An object with `sharingModel: 'controlled_by_parent'` **must declare exactly one required `master_detail` field**; its `reference` identifies the master object and its field key is the master FK. Validation error otherwise (fail closed — an unsatisfiable "controlled by parent" must not silently fall open).
40+
⚠️ **Amended 2026-11-06 (#30905, ruling A):** or, with no required one, its **only** `master_detail` declared `required: false` — an **optional** master, whose rows may name none. Every other refusal stands (two masters, a required `lookup` only, a `readonly` / `system` master). See the amendment at the end.
3941
- The author writes **no RLS policy** for this — "controlled by parent" is *derived automatically* from the relationship (Salesforce-like OWD), which is the whole point.
4042

4143
### 2. Read mechanism — pre-resolved accessible-master-id set (chosen)
@@ -47,6 +49,8 @@ For a `controlled_by_parent` object, the security layer, per request:
4749

4850
This composes with the detail's own tenant/owner RLS (all AND-ed) and flows to analytics via `getReadFilter` unchanged.
4951

52+
⚠️ **Amended 2026-11-06 (#30905, ruling A):** for an **optional** master (§1 as amended), the derivation is one `$or` of two arms that never share a row — the master arm (now ADR-0139's leaf, per the 2026-10-22 amendment) and a **null-master** arm for rows whose master reference is empty, read by the row's owner and by View All on the detail. The `$or` is the cbp layer's own answer, so it is still AND-ed under the tenant wall and beside the detail's own layers. A required master's derivation is unchanged. See the amendment at the end.
53+
5054
**Why this mechanism (trade-offs):**
5155

5256
| Option | Verdict | Why |
@@ -190,3 +194,39 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to
190194
### What is unchanged
191195

192196
§1 (one required `master_detail`, no author-written derivation), §2 (read derived from the master), the chain walk, the cycle guard and the depth bound all stand. Every detail that does not declare the opt-in behaves byte-for-byte as before this amendment.
197+
198+
---
199+
200+
## Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads
201+
202+
**Ruling.** objectstack-ai/objectstack#30905, maintainer ruling 「A」 (2026-11-06): `controlled_by_parent` admits an optional master, and a detail row with no master is visible only to its owner — the Salesforce reading of a contact with no account, a private contact. Rejected with it: a membership key collecting its object's own id, one-hop RLS traversal, and no platform change. The ruling lands in three serial changes; this amendment records the first (spec, lint, the read). The write gate (§3) and the docs follow in their own changes, and this record is amended again for §3 then.
203+
204+
### The gap this closes
205+
206+
"A rep sees the contacts of the accounts they own; a contact need not have an account" had no declarative form. §1 required the master reference on every row, the parse and `ObjectSchema.create()` refused a `master_detail` declared `required: false`, a `lookup` derives no access, and RLS cannot read the master's owner. The one working route was a copied owner field kept in step by hooks.
207+
208+
### What changes, exactly
209+
210+
- **Spec (§1).** The object's **only** `master_detail`, declared `required: false`, is its master when it declares no required one — an **optional** master. The parse's one-master rule (`packages/spec/src/data/controlled-by-parent-master.ts#controlledByParentMasterRefusal`) judges the master through `controlledByParentMasterOf`, exported beside it, which answers the field and whether it is optional; `isControlledByParentMasterField` keeps meaning the REQUIRED master. `ObjectSchema.create()` keeps an explicit `required: false` on the only `master_detail` as written; beside another `master_detail` it still refuses it, since that field is a second parent, not the master. Every other refusal stands: two masters, a required `lookup` only, and a `readonly` / `system` master (required or optional).
211+
- **An omitted `required` keeps meaning required where the omission is visible.** The builder forces it to `true` and the stored-row conversion `cbp-master-detail-required-defaulted` converts it to `true`, both unchanged. The **parse** cannot see it: `required` defaults to `false` on the parsed field map, so a raw parse (and `defineStack` over a plain object literal) admits an omitted `required` on the only `master_detail` as the optional master. `os lint`'s `relationship/master-detail-required` reports the omission at `error`, naming both spellings. Whether the parse should tell the two spellings apart was ruled by the maintainer on #30905 (record `6687830530`, 2026-11-06): option A, the parse does not tell them apart, and this bullet implements that ruling; option B (refusing the omitted spelling at every door through a new raw-input check) and option C (a separate explicit key) were not chosen.
212+
- **Compatibility.** No accepted stored object changes meaning. A stored row the v18 upgrade listed under `cbp-master-relation-tiers-retired` as `master-not-required` because it declared `required: false` is a valid optional-master detail now and is no longer listed; the semantic migration entry `cbp-optional-master-admitted` records it.
213+
- **Read (§2).** `packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts#computeControlledByParentFilter` answers an optional master with `{ $or: [ { fk: { $in: ReadableIds.of(master) } }, nullMasterArm ] }`. The null-master arm is `{ fk: null }` for a holder of View All on the detail (`viewAllRecords` or `modifyAllRecords`, the super-user read bypass on the detail), else `{ $and: [ { fk: null }, { owner_id: <caller> } ] }` when the detail carries the `owner_id` column, else nothing. The function is the one chokepoint: the data middleware, `getReadFilter` (analytics, and the engine's `ReadableIds` composer behind the parent gates of attachments, comments, activity and approvals, and behind a chain's next level) and the delegated read compose it, each with the permission sets of the context it is asked for, so every read door follows. A required master's leaf is byte-identical to before.
214+
- **Explain.** `security/explain` narrates the null-master arm on a record read: which arm returned the row, or, for a row the read door withholds, that it names no master and who reads it instead (`packages/plugins/plugin-security/src/explain-engine.ts`).
215+
216+
### The rules the read keeps
217+
218+
- A row that names a master is never read through the null-master arm, and a row that names none never through the master arm (`$in` never matches an empty reference).
219+
- View All on the detail admits the null-master rows and nothing more: it does not open the master arm, which stays the master's own read answer.
220+
- A null-master row with no owner (an ADR-0142 queue row), or on an object with no `owner_id` column (`ownership` other than `'user'`), is read by View All alone until it is claimed.
221+
- In a chain, an intermediate row whose own master is empty is read by its owner, and the next level's master arm sees exactly that: its leaf is composed with the intermediate level's own read scope, the `$or` included.
222+
- A caller who reads another master does not read the null-master rows they do not own.
223+
224+
### Fail-closed boundaries
225+
226+
- An omitted `required` on an object that reached the registry without the parse resolves no master (the runtime reads `required === false`, not its absence), so every read is denied, as before.
227+
- A cycle or a depth refusal of the chain still denies the whole object, the null-master rows included: the declared chain is the defect.
228+
- The write gate is unchanged: an insert or update that leaves the master reference empty is refused (`MasterReferenceMissingError`, 422), so no null-master row can be written through the platform until §3 is amended.
229+
230+
### What is unchanged
231+
232+
§3, the chain walk, the cycle guard and the depth bound; every `controlled_by_parent` object with a required master, byte for byte; the owner opt-in of the 2026-10-26 amendment.

0 commit comments

Comments
 (0)