You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit aa3e21c
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
|**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) |
242
242
243
243
<Callouttype="warn">
244
244
These are the **only four canonical values** (ADR-0090 D4). The legacy spellings
|`private`| Owner only (+ scope-depth grants and record shares) |
31
31
|`public_read`| All users can read; writes stay owner-scoped |
32
32
|`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|
34
34
35
35
> **The default is `private`, fail-closed (ADR-0090 D1).** A custom object
36
36
> that declares **no `sharingModel`** — or that stores an unknown value — is
Copy file name to clipboardExpand all lines: docs/adr/0055-master-detail-controlled-by-parent.md
+26-4Lines changed: 26 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,7 +5,8 @@
5
5
· **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).
6
6
· **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
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".
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.
**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
66
67
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
67
68
-**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).
68
69
⚠️ **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.
69
71
70
72
This reuses the existing re-read-and-deny pattern; no new enforcement layer.
71
73
@@ -199,7 +201,7 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to
199
201
200
202
## Amendment (2026-11-06, #30905): an optional master, and the null-master row its owner reads
201
203
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).
203
205
204
206
### The gap this closes
205
207
@@ -225,8 +227,28 @@ The ruling names **update**. So the leg does not reach: an **insert** (no row to
225
227
226
228
- 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
229
- 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.
229
231
230
232
### What is unchanged
231
233
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.
Copy file name to clipboardExpand all lines: skills/objectstack-data/references/lint-rules.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,7 @@ Data-model rules (in addition to naming/label/i18n):
10
10
| Rule | Severity | Catches |
11
11
|---|---|---|
12
12
|`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`|
14
14
|`relationship/delete-behavior`| suggestion |`master_detail` without an explicit `deleteBehavior`|
15
15
|`relationship/line-items-inline-edit`| suggestion | a `*_line`/`*_item` master_detail child without `inlineEdit`|
16
16
|`relationship/line-item-should-be-master-detail`| suggestion | a line-item-shaped child using `lookup` instead of `master_detail`|
0 commit comments