Skip to content

Commit aa3e21c

Browse files
docs(skills): teach controlled_by_parent with an optional master in the data skill, permission docs and ADR-0055 (#31092)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xWhCfdfW618LQz4eew6Gh2g)_ Fixes #30907 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:数据技能(`skills/objectstack-data` 的 `rules/relationships.md`、`rules/security.md`、`references/lint-rules.md`、`SKILL.md`)、五个权限文档页和 ADR-0055 现在教「可选主表」:`controlled_by_parent` 子对象把它唯一的 `master_detail` 写成 `required: false`,不再教「复制一个属主字段再用钩子同步」。没挂主表的行归属主:属主或子对象「查看全部」可读,属主或子对象「修改全部」可写;新建不挂主表的行直接放行;挂主表要对新主记录有编辑权,清空主表要对原主记录有编辑权(`ownerMayEdit` 不覆盖);链上某跳没有主表就在那里按同一规则结束。另加一句:成员键的 `valueField` 不能是名册对象自己的 `id`(`membership-key-value-field-invalid`)。ADR-0055 新增 2026-11-07 的 §3 修订,并就地标注 2026-11-06 那行「§3 不变」。 - **为什么改**:您对 #30905 的裁决「A」分三张卡落地;第 1、2 张(PR #30940、#31082)已合并,平台已支持可选主表的读和写,但技能和文档还在教「主表必须必填」或复制属主字段的老路。这是第 3 张。 - **风险与回滚**:只改技能文本、文档、ADR 和一个 changeset,无运行时影响;四个技能文件都在 token 天花板内(棘轮行不动);回滚即 revert。 - **先看**:`skills/objectstack-data/rules/relationships.md` 新增的「Optional master」小节,以及 ADR-0055 末尾的 2026-11-07 修订。 - **两分钟验证**:`node scripts/check-skills-token-ratchet.mjs`,预期 exit 0,读数见下文 Token budget。 - **需要你做的**:`skills/**` 和 `docs/adr/**` 属 Tier H,需要 `os-zhuang` 或 `hotlong` 批准。 ## What changes, and the code behind each taught fact Every fact was read on `main` `719a502b` (after PR #30940 for #30905 and PR #31082 for #30906): | Taught | Source on `main` | |:--|:--| | The object's **only** `master_detail` declared `required: false` is the master when no required one stands (an optional master) | `packages/spec/src/data/controlled-by-parent-master.ts` `controlledByParentMasterOf`, judged by `controlledByParentMasterRefusal` (the parse's refinement in `object.zod.ts`) | | Still refused: `required: false` beside a second `master_detail`; a `readonly` / `system` master (required or optional); an omitted `required` is forced to `true` by `ObjectSchema.create()` | same module (`several-masters`, `master-not-writable`); `object.zod.ts` `forceCbpMasterDetailRequired`; lint `relationship/master-detail-required` (`packages/lint/src/data-model-rules.ts` `cbpMasterReferenceFinding`) | | A null-master row is read by its owner (`owner_id`) and by View All on the detail; no owner / no `owner_id` column ⇒ View All alone | `packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts` `computeControlledByParentFilter` (the `nullMasterArm`) | | A null-master row is written by its owner or Modify All on the detail (everyone else `403`, View All included); no owner ⇒ Modify All alone | `security-plugin-cbp-write-gate.ts` `nullMasterRowWritable`, and the `masterId == null && rel.optional` block of `assertControlledByParentWrite` | | An insert naming no master is admitted | same block: `if (operation === 'insert') return;` | | Setting a master asks edit on it; emptying one asks edit on the stored master, and `ownerMayEdit` does not cover it | `askBodyNamedMaster` after the owner rule; the `ownerMayEdit` leg's `!(rel.optional && bodyEmptiesMasterReference(...))` | | A chain hop with no master ends there under the same rule | the chain walk's `nextMasterId == null && nextRel.optional` branch (`nullMasterRowWritable` on the master's own object) | | A membership key's `valueField` cannot be the roster's own `id` | `packages/lint/src/validate-membership-key-declarations.ts`: `columnOf` maps `id` to an empty slice, `valueFieldFault` reports it `membership-key-value-field-invalid` | **Skills.** `rules/relationships.md` gains § Optional master (after the `ownerMayEdit` section), and its `master_detail` table row names it. `rules/security.md`: the "choose by what the rows are keyed on" list gains "a parent's owner, parent optional → an optional master", and the `valueField` note gains the `id` refusal. `SKILL.md`: the OWD table's `controlled_by_parent` row says a row with no (optional) master is its owner's. `references/lint-rules.md`: the `relationship/master-detail-required` row names the optional master. **Docs** (`content/docs/permissions/`): `authorization.mdx` (the lint-gate bullet, ≈L539: one master, required or optional, and what `no-relation` / `ambiguous-relation` now catch), `rls.mdx` (the controlled-by-parent bullet), `sharing-rules.mdx` (OWD table row), `permissions-matrix.mdx` (OWD table row), `index.mdx` (a comment on the example's `contact: 'controlled_by_parent'`). **ADR-0055.** A new section "Amendment (2026-11-07, #30906): the null-master row its owner writes", a status line for it, and an inline ⚠️ note at §3. The 2026-11-06 status line's "§3 … is unchanged" is marked in place (⚠️ Amended 2026-11-07), as are the three sentences inside the 2026-11-06 amendment that said the same (its Ruling paragraph's "amended again for §3 then", its Fail-closed bullet on the write gate, its "What is unchanged" §3). Nothing is rewritten. ## Token budget `node scripts/check-skills-token-ratchet.mjs` (convention `ceil(utf8 bytes / 4)`), no ratchet row moved, `scripts/check-skills-token-ratchet.mjs` untouched: | File | `main` | head | ceiling | |:--|--:|--:|--:| | `skills/objectstack-data/SKILL.md` | 6078 | 6071 | 6078 | | `skills/objectstack-data/rules/security.md` | 3688 | 3696 | 3698 | | `skills/objectstack-data/rules/relationships.md` | 3838 | 3870 | 3873 | | `skills/objectstack-data/references/lint-rules.md` | 935 | 958 | 970 | (The order's SKILL.md reading of 6068 had moved to 6078/6078 on `main` `719a502b`.) No copied-owner-field recipe existed in these files to replace, so the growth is paid by deleting restatement in the same file; `lint-rules.md`'s +23 is one table row and stays under its ceiling with no deletion. Each deletion and its surviving home: `rules/relationships.md` 1. The intro "Guide for modeling relationships between objects using `lookup`, `master_detail`, and junction patterns." Home: the H1 and the ToC lines above it. 2. § Delete Behaviors' fence `{ type: 'master_detail', reference: 'parent_object', deleteBehavior: 'cascade', // or 'restrict' — 'set_null' is refused here }`. Home: the sentence and table right above it ("`cascade` or `restrict` **only**; an authored `set_null` is refused"). 3. § Roll-up Summaries "and never summed on the client". Home: "**server-side**" in the same sentence. 4. § Roll-up Summaries "Pairs naturally with inline editing (below): the parent total updates atomically as line items are saved." Home: the same section's "inside the same transaction as the write" and § Inline Editing's "saves parent + children in **one atomic transaction**". 5. § Inline Editing "— with no form-view config and no bespoke page". Home: the section's first sentence ("in the data model — not in a form view"). 6. § Inline Editing "but the relationship `inlineEdit` is the primary, zero-config path". Home: the same first sentence. 7. ❌ Incorrect's comment `// Invoice line items should NOT be independent`. Home: the heading and the fence's own `// ❌ Child can exist without parent — wrong!`. 8. ✅ Correct's fence (`invoice_line_item` with a required `master_detail`, `deleteBehavior: 'cascade'`), replaced by a link to § One-to-Many: master_detail, which declares the same object and field. `rules/security.md` 1. The RLS example's comments `// rows readable / targetable` and `// every row written`. Home: the prose right above ("`using` admits rows …", "`check` is judged on every row an `insert`/`update` writes"). 2. § Membership keys intro "rather than on a person or a position". Home: the same section's "Choose by what the rows are keyed on" list. `SKILL.md` 1. "See [rules/relationships.md](./rules/relationships.md) for detailed examples." under the Relationship Patterns table. Home: the Quick Reference link to Relationships. ## Eval sweep `grep` over `skills/objectstack-data/evals/*.json` for `controlled_by_parent`, `master_detail`, `required`, `owner`, `hook`, `copy`/`mirror`/`stamp` and `membership`: **no case pins the old rule.** `objects-fields-relationships.json` case 2 expects `required: true` on an invoice line's `master_detail`, which stays right (a line always has its invoice). `hooks-security-seeds-datasources.json` case 6 (membership keys for portal orders) and case 1 (the search-mirror hooks) teach rules this card leaves standing. No eval file is changed. ## Scorer check (read-only) `packages/qa/scenarios/src/score/scenario-tags.ts` row-scope check, now at **L393** (the card's L343 drifted): `if (effectiveSharingModel(def) === 'private' || def.sharingModel === 'controlled_by_parent') return check(at, true, model);`. It accepts any `controlled_by_parent` object without reading its master's `required`, so an optional-master detail passes. No change. ## Spec contract jsdoc owed (spec lane, not edited here) The wording `packages/spec/src/contracts/security-service.ts` owes (`master_chain`, `master_reference_missing`, `checkControlledByParentWrite`) is filed for the spec lane as #31094. ## Other `content/docs` pages that still teach a required-only master Listed, not widened: filed as #31095 (`error-handling.mdx`, `error-catalog.mdx`, `cli.mdx`, including the two sites the contract review added). Release pages (`content/docs/releases/**`) are release-owned. ## Review round 1 Contract review FAIL at `197aa7c88` (comment 6703355433). Fixed in delta commit `9cea5ade1`, as the seat ordered (one new commit, no rebase or force-push): - **M1** (`rules/relationships.md` § Optional master): "emptying one asks edit on the stored master, `ownerMayEdit` aside" now reads "…, even under `ownerMayEdit`", matching the owner leg's `!(rel.optional && bodyEmptiesMasterReference(opCtx.data, rel.fk))` in `security-plugin-cbp-write-gate.ts`. - **S1** (`rules/relationships.md` § Owner edits): "every write needs edit access on the master" now reads "every write of a row naming a master needs edit on it", and "on-behalf-of writes still need master edit" reads "on-behalf-of writes under a master still need its edit", so neither contradicts § Optional master (an insert naming no master is admitted; the owner rule governs a null-master row). - **S2** (ADR-0055, 2026-11-07 amendment): the owner-rule bullet's `403` is now said of a principal who reaches the gate; one who cannot read the row meets the read door's `404 RECORD_NOT_FOUND` first (step 2.7), as #31082 pinned. - **S3 / S4**: the other docs pages and the spec jsdoc are filed by the seat as #31095 and #31094; this body points to them instead of listing them. The delta costs `rules/relationships.md` 6 tokens (3865 → 3871, ceiling 3873); no deletion was added and no ratchet row moved. ## Review round 2 Delta review PASS at `9cea5ade1` (comment 6703520499), no must-fix. Its should-fix **S5**, closed here as the seat ordered, in delta commit `a60b01a6e`: `rules/relationships.md` § Owner edits opened "A `controlled_by_parent` detail inherits the master's access: reads are scoped to accessible masters", which left out the null-master read (the owner and View All, § Optional master). It now reads "A `controlled_by_parent` detail row naming a master inherits its access: reads are scoped to accessible masters and **every write needs edit on the master**", so the read and the write halves both speak of a row that names a master (S1's write qualifier moves into the subject). The change is net −1 token (3871 → 3870, ceiling 3873); no deletion was needed and no ratchet row moved. ## Review tier `dispatch-gates` mandates the contract-review tier for `skills/**` and `docs/adr/**` (clause ①); that is waived by the maintainer's standing order in this project (2026-11-03). The seat runs the contract review. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `a60b01a6e` (11 paths vs merge base `719a502b2`): **75 commands**, the same list as rounds 1 and 2, each run sequentially under `timeout 1200` with `NODE_OPTIONS=--max-old-space-size=8192` (raised from the cloud container's default heap) over the full `pnpm build` (119/119 tasks), exit code captured before any pipe → **75 × exit 0**, none re-run. `--ran` reconciliation: `75 derived famil(ies) accounted for — 75 run, 0 NOT-MEASURED`. Rounds 1 (`197aa7c88`) and 2 (`9cea5ade1`) were also 75 × exit 0. `origin/main` has moved since, touching none of these paths, so no merge was needed. `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` → exit 0 before each push. <details><summary>Ran list (head a60b01a)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-skills-token-ratchet.mjs :: exit 0 node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-docs :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:adr-anchors :: exit 0 pnpm check:adr-index :: exit 0 pnpm check:adr-symbol-anchors :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-compatibility :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (`'@objectstack/skills': patch`), and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [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.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAo8GHSrxa8ZR8h3qxs <!-- signed: domain:skills#3 · huangyiirene · session_01J8VVAj7CvjkGXvgXzd6RZ7 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 115bf92 commit aa3e21c

11 files changed

Lines changed: 86 additions & 56 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the data skill teaches `controlled_by_parent` with an optional master
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`objectstack-data` now teaches "a rep sees the contacts of the accounts they own; a contact need not have an account" as a `controlled_by_parent` detail whose only `master_detail` is declared `required: false` (an optional master), not as an owner field copied by hooks. A row with no master is its owner's: the owner or View All on the detail reads it, the owner or Modify All writes it, an insert naming no master is admitted, setting a master asks edit on it, emptying one asks edit on the stored master (`ownerMayEdit` does not cover that), and a chain hop with no master ends there under the same rule. The `relationship/master-detail-required` lint row names the optional master, and the membership-key notes say a `valueField` cannot be the roster's own `id` (`membership-key-value-field-invalid`). The platform behaviour shipped earlier in `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-security`; this release only teaches it.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -537,11 +537,14 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
537537
wildcards carrying View/Modify All outside the platform admin set,
538538
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
539539
"role" in security identifiers, a `controlled_by_parent` object without
540-
exactly one required `master_detail` to derive access from (ADR-0055 §1:
541-
`security-controlled-by-parent-no-relation` when it declares none — a
542-
`master_detail` without `required: true` and a required `lookup` derive
543-
nothing — and `security-controlled-by-parent-ambiguous-relation` when it
544-
declares two or more; either way the runtime denies every read and refuses
540+
exactly one master to derive access from (ADR-0055 §1 as amended: one
541+
`master_detail` declared `required: true`, or, with none, its only
542+
`master_detail` declared `required: false`, an optional master;
543+
`security-controlled-by-parent-no-relation` when it declares none — two
544+
`master_detail` fields with none required, one that writes no `required`,
545+
and a required `lookup` derive nothing — and
546+
`security-controlled-by-parent-ambiguous-relation` when it declares two or
547+
more required ones; either way the runtime denies every read and refuses
545548
every write), and the ADR-0091
546549
grant-lifecycle rules (a seed grant already expired at authoring time; a
547550
delegation row missing its mandatory `reason`) — every error rule mirrors

‎content/docs/permissions/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ Complete security setup for a sales team:
230230
const OrganizationDefaults = {
231231
account: 'private',
232232
opportunity: 'private',
233-
contact: 'controlled_by_parent',
233+
contact: 'controlled_by_parent', // its master_detail to account; with required: false, a contact with no account is its owner's (ADR-0055 §1)
234234
};
235235

236236
// 2. Positions (flat — the hierarchy lives on business units)

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ OWD sets the baseline access level for each object across the entire organizatio
238238
| **Public Read/Write** | `public_read_write` | All users | All users | Low-sensitivity data (e.g., tasks, wiki pages) |
239239
| **Public Read Only** | `public_read` | All users | Owner + shared | Moderate sensitivity (e.g., accounts, contacts) |
240240
| **Private** | `private` | Owner + shared | Owner + shared | High sensitivity (e.g., opportunities, HR records) |
241-
| **Controlled by Parent** | `controlled_by_parent` | Derived from the master record | Derived from the master record | Master-detail children (e.g., order lines) |
241+
| **Controlled by Parent** | `controlled_by_parent` | Derived from the master record; a row with no (optional) master: its owner + View All | Derived from the master record; a row with no (optional) master: its owner + Modify All | Master-detail children (e.g., order lines; a contact whose account is optional) |
242242

243243
<Callout type="warn">
244244
These are the **only four canonical values** (ADR-0090 D4). The legacy spellings

‎content/docs/permissions/rls.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ Layer 1 (business RLS) ─┘
239239
- A superuser bypasses business policies but **not** tenant isolation —
240240
crossing tenants requires a genuine platform admin.
241241
- Children of a master-detail parent with `sharingModel: 'controlled_by_parent'`
242-
derive access from the master record instead.
242+
derive access from the master record instead. A child whose master is
243+
optional (`required: false`, ADR-0055 §1 as amended) and empty is read by its
244+
owner and by View All on the child, and written by its owner and by Modify
245+
All on the child.
243246

244247
## The fail-closed contract
245248

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const LeaveRequest = ObjectSchema.create({
3030
| `private` | Owner only (+ scope-depth grants and record shares) |
3131
| `public_read` | All users can read; writes stay owner-scoped |
3232
| `public_read_write` | All users can read and edit |
33-
| `controlled_by_parent` | Access follows the master record (master-detail children) |
33+
| `controlled_by_parent` | Access follows the master record (master-detail children); a row whose optional master is empty is its owner's, plus View All / Modify All on the object |
3434

3535
> **The default is `private`, fail-closed (ADR-0090 D1).** A custom object
3636
> that declares **no `sharingModel`** — or that stores an unknown value — is

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
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.
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. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082)** — that §3 sentence no longer holds; see the next line. See **"Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads"** at the end.
9+
· **Amended** (2026-11-07, #30906 / PR #31082, the same ruling 「A」) — **§3 judges the null-master row by an owner rule**: a row of an optional master whose reference is empty is written by its owner (`owner_id`) and by Modify All on the detail, everyone else `403`; an insert naming no master is admitted; setting a master asks edit on it, emptying one asks edit on the stored master (the 2026-10-26 opt-in does not cover it); a chain hop with no master of its own ends the chain under the same rule. A required master is unchanged. See **"Amendment (2026-11-07, #30906): the null-master row its owner writes"** at the end.
910
**Deciders**: ObjectStack Protocol Architects
1011
**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)
1112
**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).
@@ -66,6 +67,7 @@ In the pre-image block (`packages/plugins/plugin-security/src/security-plugin.ts
6667
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
6768
- **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2).
6869
⚠️ **Amended 2026-10-26 (#25760) — one opt-in.** A detail that declares `sharing.controlledByParent.ownerMayEdit: true` lets the **owner** of a detail row (`owner_id`) pass this gate on a **by-id `update`** of that row with **read** access to the master. The rule above stands for every other caller and every other write; see the Amendment at the end before deriving an invariant from this line.
70+
⚠️ **Amended 2026-11-07 (#30906 / PR #31082) — the null-master row.** For an **optional** master (§1 as amended), a row whose master reference is empty has no master to ask: its owner or Modify All on the detail writes it, an insert naming no master is admitted, and emptying a master asks edit on the stored master. See the 2026-11-07 amendment at the end.
6971

7072
This reuses the existing re-read-and-deny pattern; no new enforcement layer.
7173

@@ -199,7 +201,7 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to
199201

200202
## Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads
201203

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.
204+
**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. ⚠️ **Amended 2026-11-07:** it was — see the 2026-11-07 amendment (#30906 / PR #31082).
203205

204206
### The gap this closes
205207

@@ -225,8 +227,28 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to
225227

226228
- 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.
227229
- 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.
230+
- 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. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082) — no longer:** for an optional master §3 now judges such a write by the owner rule; a required master's empty reference is still refused. See the 2026-11-07 amendment.
229231

230232
### What is unchanged
231233

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.
234+
§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. ⚠️ **Amended 2026-11-07 (#30906 / PR #31082):** §3 and the chain walk changed for the null-master row; see the 2026-11-07 amendment.
235+
236+
## Amendment (2026-11-07, #30906): the null-master row its owner writes
237+
238+
**Ruling.** The same maintainer ruling 「A」 of #30905 (2026-11-06): a detail row with no master is its owner's. The 2026-11-06 amendment recorded the first of three changes (spec, lint, the read) and left §3 refusing every write that leaves an optional master empty. The second, #30906 (PR #31082, merged 2026-11-07), amends §3; this section records it. The third, #30907, teaches it in the docs and the authoring skill.
239+
240+
### What §3 does now, exactly
241+
242+
The gate (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#assertControlledByParentWrite`) judges a row of an **optional** master whose master reference is empty instead of refusing it `422`:
243+
244+
- **The owner rule** (`packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#nullMasterRowWritable`). Such a row is written by its owner (`owner_id`, non-null, equal to the principal) and by a holder of Modify All (`modifyAllRecords`) on the detail. Everyone else who reaches the gate, View All included, is refused `403 PERMISSION_DENIED`, leg `master_chain`, naming the row; a principal who cannot read the row meets the read door first (`404 RECORD_NOT_FOUND`, step 2.7), as for every hidden row. A row with no owner (an ADR-0142 queue row), and every row of an object with no `owner_id` column, is Modify All's alone. It is the write twin of §2's null-master arm: View All reads such a row, Modify All writes it.
245+
- **Insert.** An insert, single or batch, that names no optional master is admitted, and its creator becomes its owner. Every master a batch's rows do name is still asked.
246+
- **By-id update and delete** of a stored null-master row, and each null-master row a predicate write (`multi: true`) selects: the owner rule.
247+
- **Setting a master** on such a row asks the row's owner rule, then edit on the new master. **Emptying one** asks edit on the stored master through the §3 walk; the 2026-10-26 opt-in (`ownerMayEdit`) does not cover a body that empties an optional master.
248+
- **Chains.** A hop whose master is itself `controlled_by_parent` with an optional master of its own, left empty, ends the chain there and asks that row's owner rule, on its own object.
249+
- **On behalf of.** Each pass (agent, then delegator) asks the owner rule of its own principal. `ISecurityService.checkControlledByParentWrite` answers such a row `allow`, or `deny` with leg `master_chain`, never `unresolvable`.
250+
- **Proof (§4, ADR-0054).** `packages/verify/src/parent-write-reach.ts` proves an optional-master detail both under a master and with none (`masters: 'none'`), with the owner rule as the expectation.
251+
252+
### What is unchanged
253+
254+
Every `controlled_by_parent` object with a required master, byte for byte: its empty reference is still refused (`422 MISSING_REQUIRED_FIELD`). Ownership transfer stays on `allowTransfer`. §1, §2, the cycle guard and the depth bound.

‎skills/objectstack-data/SKILL.md‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ the publish door rejects it before that:
7373
| `private` | owner only (widen with sharing rules, RLS, or `readScope`/`writeScope`) |
7474
| `public_read` | everyone reads; the owner writes |
7575
| `public_read_write` | everyone reads and writes |
76-
| `controlled_by_parent` | inherited from the master record (`master_detail` children) |
76+
| `controlled_by_parent` | inherited from the master record (`master_detail` children); a row with no (optional) master is its owner's |
7777

7878
**Important optional properties:**
7979

@@ -291,8 +291,6 @@ via the registered `ICryptoProvider`, masked on read, fail-closed, ADR-0100) —
291291
| Many-to-Many (with attributes) | Junction object with two `lookup` fields |
292292
| Hierarchical | `tree` field (self-reference) |
293293

294-
See [rules/relationships.md](./rules/relationships.md) for detailed examples.
295-
296294
> **`multiple: true` lookup ≠ junction object.** A multi-value lookup
297295
> (`{ type: 'lookup', reference: 'x', multiple: true }`) is stored and read as an
298296
> **array of ids** on the record — reference elements positionally

‎skills/objectstack-data/references/lint-rules.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Data-model rules (in addition to naming/label/i18n):
1010
| Rule | Severity | Catches |
1111
|---|---|---|
1212
| `relationship/missing-reference` | error | lookup/master_detail without a `reference` target |
13-
| `relationship/master-detail-required` | warning; error under `controlled_by_parent` | a `master_detail` that isn't `required` — or, under `controlled_by_parent`, is `readonly`/`system` |
13+
| `relationship/master-detail-required` | warning; error under `controlled_by_parent` | a `master_detail` that isn't `required` (under `controlled_by_parent`, the only one may be `required: false`: the optional master) — or, under `controlled_by_parent`, is `readonly`/`system` |
1414
| `relationship/delete-behavior` | suggestion | `master_detail` without an explicit `deleteBehavior` |
1515
| `relationship/line-items-inline-edit` | suggestion | a `*_line`/`*_item` master_detail child without `inlineEdit` |
1616
| `relationship/line-item-should-be-master-detail` | suggestion | a line-item-shaped child using `lookup` instead of `master_detail` |

0 commit comments

Comments
 (0)