Skip to content

Commit 13c6d8f

Browse files
docs: error-handling, error-catalog and cli pages teach the optional controlled_by_parent master (#31164)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiNjyvCdXYygFqntMNR7S2dW)_ Fixes #31095 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:错误处理页、错误目录页和 CLI 页不再说「`controlled_by_parent` 子对象的主表引用存空一律 422」。现在写明:只有必填主表(`required: true`)的存储空引用才回 `422 MISSING_REQUIRED_FIELD`;可选主表(唯一的 `master_detail` 写 `required: false`)的空引用不是错误,这行归属主——属主和子对象「查看全部」可读,属主和「修改全部」可改删,其他人 403。CLI 页的 v18 升级清单不再把显式 `required: false` 列为被拒形态,`os verify --parent-write-reach` 段补上它对「不挂主表的行」的证明。 - **为什么改**:您对 #30905/#30906 的裁决「A」已经在代码里落地(读、写两半都已合并),权限文档页也已改好;这三页参考文档还在教旧规则,读者按它处理 422 会判断错。 - **风险与回滚**:只改三个 `content/docs` 页面,不发布任何包,无运行时影响;回滚即 revert。 - **先看**:`content/docs/protocol/kernel/error-handling.mdx` 的 `MISSING_REQUIRED_FIELD` 小节(新增「可选主表」段和声明表新行),其次 `content/docs/api/error-catalog.mdx` 同名小节。 - **两分钟验证**:`pnpm check:doc-anchors && pnpm check:doc-authoring`,预期两者 exit 0;再打开 error-handling 页的 `MISSING_REQUIRED_FIELD` 小节,应看到声明表里 `required: false` 一行写着「not refused」。 - **需要你做的**:无,席位落地。 ## What changes Three `content/docs` pages taught a `controlled_by_parent` master that is always required. Each site was re-read against the code on `main` and reworded; the wording follows the permissions pages (`content/docs/permissions/authorization.mdx`, `sharing-rules.mdx`, `rls.mdx`, `permissions-matrix.mdx`): "its only `master_detail` declared `required: false`, an optional master (ADR-0055 §1 as amended)", "read by its owner and by View All", "written by its owner and by Modify All". | Page · site | Was | Now | Backed by | |:--|:--|:--|:--| | `protocol/kernel/error-handling.mdx` status table (422 row) | a stored null master reference answers 422 | a stored null reference to a **required** master; an optional master's empty reference is not an error | `security-plugin-cbp-write-gate.ts`: the `masterId == null && rel.optional` block returns before `MasterReferenceMissingError`; `controlledByParentPredicateWriteMasters` throws it only when `!rel.optional` | | same page, `MISSING_REQUIRED_FIELD` exception | 422 "whatever the declaration" | 422 only where the master is `required: true` (flagged or not); new paragraph: an optional master's empty row is its owner's, read by owner + View All, updated/deleted by owner + Modify All; everyone else who reaches the gate `403 PERMISSION_DENIED`, a principal who cannot read the row `404 RECORD_NOT_FOUND` first (round 2, S1; error-catalog says the same) | write gate `nullMasterRowWritable` + `nullMasterOwnerDenied` (`PermissionDeniedError`); the read door's 404 for a hidden addressed row: `security-plugin-authz-middleware.ts` `recordNotFoundError` after `callerCannotReadAddressedRow`; read filter `computeControlledByParentFilter` `nullMasterArm` (`security-plugin-cbp-read-filter.ts`) | | same page, declaration table | three rows, all `required`; "Only the first row is a declaration the parse accepts" | adds `required: false` (optional master): an insert that leaves it out is not refused, an update that empties it is judged by the master it leaves (`403 PERMISSION_DENIED` without edit access) (round 2, S2; error-catalog prose says the same); flagged rows read "required or optional"; "Only the first two rows"; insert naming no master admitted, emptying asks edit on the master it leaves, `ownerMayEdit` does not cover it | write gate: `if (operation === 'insert') return;`, `askBodyNamedMaster` (`rel.optional` return), the `ownerMayEdit` leg's `!(rel.optional && bodyEmptiesMasterReference(...))`; `rel.masterNotWritable` refusal ahead of the optional block; `controlledByParentMasterRefusal` judges `master-not-writable` on the optional master too (`controlled-by-parent-master.ts`) | | same page, "no master at all" paragraph | master = exactly one `master_detail` declared `required: true`; a `master_detail` without `required` derives none | master = the required one, or with none the only `master_detail` declared `required: false`; no master: two `master_detail` none required, two required, required `lookup` only; an omitted `required` is forced to `true` by `ObjectSchema.create()` and reported by `os lint` at `error`, while a raw parse reads it as `false` | `controlledByParentMasterOf` (`controlled-by-parent-master.ts`); `object.zod.ts` `refuseControlledByParentWithoutOneMaster` docblock (the amendment note on the omitted `required`) and `forceCbpMasterDetailRequired` | | same page, "Why the gate refuses" | `fk IN (readable masters)` can never match | the same, said of a required master's filter, which has no empty-reference arm | read filter: `if (!rel.optional) return masterArm;` | | `api/error-catalog.mdx` `MISSING_REQUIRED_FIELD` Fix line, callout, "no master" paragraph, "not relaxed" paragraph | same four claims as above | same rewording, condensed | same sources | | same page, status table (422 row) | stored null master reference | stored null reference to a required master; optional master's empty reference is not an error | same | | `deployment/cli.mdx` v18 "Not rewritten" table | lists "an explicit `required: false`" as a refused stored shape | it is the optional master, admitted by `cbp-optional-master-admitted` and not listed | `refused-shapes.ts` `controlledByParentWithoutOneMaster` (judges with `controlledByParentMasterRefusal`); `migrations/entries/semantic/18.cbp-optional-master-admitted.ts` | | same page, `--parent-write-reach` paragraph | "exactly one `master_detail` field names its master" | "required or optional"; for an optional master the run also proves null-master records: edit/delete = verb on the detail and (owner or Modify All), create naming none = create on the detail; an unreadable one is not probed | `packages/verify/src/parent-write-reach.ts` module header and `nullMasterReach`; `deriveParentWriteReachCases` marks `optionalMaster` from `controlledByParentMasterOf` | No `content/docs/releases/**` page is touched. ## Tests Docs only: no code, no test. The derived docs gates below are the verification. ## Acceptance notes - `skip-changeset`: the diff touches only `content/docs/**`, which no package's `files[]` ships. - Left as found (not this card's wording): the CLI v18 table's `controlled_by_parent` parenthetical does not name the `master-not-writable` shape (a `readonly` / `system` master), which `refused-shapes.ts` lists too; the row's "an object registers but derives no master" is not true of that shape (it keeps its master and loses the save door). The 422 exception names "an update or delete by id"; a predicate write that selects such a row answers the same 422 (`controlledByParentPredicateWriteMasters`). ## Verification Gate run on head `ba96749d36` (round 2; round 1 ran the same 45 on `f28b6db045`, after merging `origin/main`); every command `dispatch-gates --commands` derives for this diff, exit codes captured before any pipe. <details><summary>Derived gates: 45 commands, all exit 0</summary> ```text node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: 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-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.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-examples :: 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:ci-filter-parity :: exit 0 pnpm check:cli-examples-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:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: 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-identifier-liveness :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` `--ran` reconciliation: `45 derived, 45 run, 0 NOT-MEASURED, 0 UNRUN` — all 45 recorded an exit code, none of them 3 (`dispatch-gates --ran`, exit 0). Five gates first refused with exit 3 for a missing build (`@objectstack/lint`, `@objectstack/spec` schema, `@objectstack/client-react` declarations); the closures were built and every gate was rerun; round 2 reran all 45 on `ba96749d36`, all exit 0. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- signed: domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb68b98 commit 13c6d8f

3 files changed

Lines changed: 112 additions & 64 deletions

File tree

‎content/docs/api/error-catalog.mdx‎

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -181,28 +181,40 @@ substitute. See the [Data API](/docs/api/data-api).
181181
### `MISSING_REQUIRED_FIELD`
182182
**Cause:** A required field was not provided in the request body.
183183
**Fix:** Include the missing field. Check `fields` for the field name — **except** on the
184-
`controlled_by_parent` stored-master path below, where the status is `422` and no `fields`
184+
`controlled_by_parent` stored required-master path below, where the status is `422` and no `fields`
185185
array is sent; read the field name out of the message there.
186186
**Retry:** `no_retry`
187187

188188
<Callout type="warn">
189189
**`MISSING_REQUIRED_FIELD` is `400` with one exception: a `controlled_by_parent` detail
190-
whose stored master reference is null answers `422`, without `fields`.**
190+
whose stored reference to a required master is null answers `422`, without `fields`.**
191191

192192
An object whose `sharingModel` is `controlled_by_parent` derives its access from a master
193193
record, so the gate authorizing writes to it resolves that master *before* the executor —
194194
and the executor is where required-field validation runs. An update or delete **by id** of
195-
a detail record whose *stored* master reference is null answers
196-
`422 MISSING_REQUIRED_FIELD`, whatever the declaration: the caller sent no such field, so
197-
nothing could be named in `fields` and no payload would fix it.
195+
a detail record whose *stored* master reference is null, where the master is declared
196+
`required: true`, answers `422 MISSING_REQUIRED_FIELD`, whether or not the master is marked
197+
`readonly` or `system`: the caller sent no such field, so nothing could be named in `fields`
198+
and no payload would fix it.
199+
200+
An optional master never answers this code. When the object's only `master_detail` is
201+
declared `required: false` (an optional master, ADR-0055 §1 as amended), a stored empty
202+
reference is not an error: the row belongs to no master and is its owner's. Its owner and a
203+
holder of View All on the detail read it; its owner and a holder of Modify All on the detail
204+
update or delete it. Everyone else who reaches the gate, View All included, is refused
205+
`403 PERMISSION_DENIED`; a principal who cannot read the row meets the read door first
206+
(`404 RECORD_NOT_FOUND`), as for every hidden row.
198207

199208
A request that leaves the master reference out, or empties it, does not answer this code.
200-
On a `master_detail` that is `required` and neither `readonly` nor `system`, validation
201-
answers it with the documented `400 VALIDATION_FAILED` and `fields`. A master reference
202-
marked `readonly` or `system` is not one an author's write supplies, so it cannot be the
203-
master (ADR-0055 §1): `ObjectSchema` refuses that declaration at parse
204-
(`master-not-writable`), and on an object that reaches the runtime without the parse, every
205-
write that sets the reference answers `422 INVALID_METADATA`:
209+
On a `master_detail` that is `required: true` and neither `readonly` nor `system`, validation
210+
answers it with the documented `400 VALIDATION_FAILED` and `fields`. On an optional master
211+
an insert that leaves it out is not refused, and its creator owns the row; an update that
212+
empties it is judged by the master it leaves, asking edit access on it
213+
(`403 PERMISSION_DENIED` without it). A master reference marked `readonly` or `system`,
214+
required or optional, is not one an author's write supplies, so it cannot be the master
215+
(ADR-0055 §1): `ObjectSchema` refuses that declaration at parse (`master-not-writable`), and
216+
on an object that reaches the runtime without the parse, every write that sets the
217+
reference answers `422 INVALID_METADATA`:
206218

207219
- every insert, single or batch, whether or not it names a master;
208220
- every update whose body carries the reference key, whatever the value, `null` included.
@@ -211,19 +223,24 @@ Its message starts `[Security] Configuration defect:` and carries the parse's ow
211223
naming the object, the field, the flag and the fix. A delete, and an update that leaves the
212224
key out, are answered as on any detail.
213225

214-
A `master_detail` with no `required`, and a `required` `lookup` on an object that declares no
215-
`master_detail`, are not master references at all: the master is exactly one `master_detail`
216-
declared `required: true` (ADR-0055 §1). `ObjectSchema` refuses such an object at parse, and
217-
one that reaches the runtime unparsed answers every write `422 INVALID_METADATA`.
226+
The master is exactly one `master_detail`: the one declared `required: true`, or, when the
227+
object declares none, its only `master_detail` declared `required: false` (ADR-0055 §1 as
228+
amended). Two `master_detail` fields with none required, two required ones, and a
229+
`required` `lookup` on an object that declares no `master_detail` name no master at all:
230+
`ObjectSchema` refuses such an object at parse, and one that reaches the runtime unparsed
231+
answers every write `422 INVALID_METADATA`. Write `required` on the master either way:
232+
`ObjectSchema.create()` forces an omitted one to `true` and `os lint` reports the omission
233+
at `error`, while a raw parse reads it as `false`, the optional master.
218234

219235
The full matrix is on the protocol page:
220236
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
221237

222238
The `INVALID_METADATA` refusal is not going to be relaxed: required-field validation skips
223239
`system` and `readonly` fields before its required check, so on those two declarations the
224-
gate is the only thing standing between the request and a detail record outside its master
225-
— which the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match,
226-
leaving a record readable by nobody.
240+
gate is the only thing standing between the request and, under a required master, a detail
241+
record outside its master — which the read filter of a required master
242+
(`fk IN (readable masters)`, with no arm for an empty reference) can never match, leaving a
243+
record readable by nobody.
227244

228245
**Branch on `code`, treat `fields` as optional, and read the status off the response**
229246
rather than deriving it from the table at the end of this page.
@@ -1011,7 +1028,7 @@ async function handleApiCall() {
10111028
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
10121029
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
10131030
| 413 | `validation` | `PAYLOAD_TOO_LARGE` — an upload over the storage settings' `max_upload_mb`, or an import over its row ceiling (see [above](#payload_too_large)) |
1014-
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on a `controlled_by_parent` detail whose stored master reference is null (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
1031+
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on a `controlled_by_parent` detail whose stored reference to a required master is null; an optional master's empty reference is not an error (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
10151032
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
10161033
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` |
10171034
| 501 | `server` | `NOT_IMPLEMENTED` — the storage routes answer it when the adapter cannot issue presigned URLs |

‎content/docs/deployment/cli.mdx‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,7 +2139,7 @@ can produce, because both supply a live one.
21392139
| Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path |
21402140
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
21412141
| A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand |
2142-
| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source or a page component whose `data` prop binds it (`object-grid`, `object-map`, `object-gantt`, `object-tree`), a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view, page component or flow node, and the owner's choices; a binding's request headers are withheld. Until it is rewritten by hand, the row is refused: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, a page still loads, its next save is refused at the metadata save door (422), and `os validate`, `os build` and `os lint` refuse the binding in source, and a flow is not registered |
2142+
| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one master, which is one `master_detail` declared `required: true` or, with none, its only `master_detail` declared `required: false` (two masters, none, a required `lookup` only; an explicit `required: false` on the only `master_detail` is that optional master, admitted by `cbp-optional-master-admitted` and not listed), a view bound to the retired view-level API data source or a page component whose `data` prop binds it (`object-grid`, `object-map`, `object-gantt`, `object-tree`), a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view, page component or flow node, and the owner's choices; a binding's request headers are withheld. Until it is rewritten by hand, the row is refused: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, a page still loads, its next save is refused at the metadata save door (422), and `os validate`, `os build` and `os lint` refuse the binding in source, and a flow is not registered |
21432143
| Any other site `registerFlow` refuses in a stored flow the parse accepts — a text slot's template hole, a decision's `mode`, a malformed predicate, a plugin node's undeclared config key, an `api` flow with no secret | The flow's own definition is wrong, and no conversion rewrites it. Each site is printed as a `TODO registerFlow` line under its row, in the words `registerFlow` refuses it with; a site already listed under a step-18 entry is not repeated. Until it is fixed, the flow is not registered at boot and never runs. A flow the parse itself refuses is reported among the rows that could not be rewritten, in the parse's words |
21442144
21452145
**One thing it lists and never writes: decision nodes that changed meaning at
@@ -2706,10 +2706,11 @@ os verify --dashboard-windows # Also prove each dashboard date
27062706
reported inconclusive, never proven.
27072707
With `--parent-write-reach`, another fresh boot proves ADR-0055's write rule
27082708
for every object declared `controlled_by_parent` (exactly one
2709-
`master_detail` field names its master): editing, deleting or creating a
2710-
detail is admitted exactly when the caller holds that verb on the detail's
2711-
object **and** can edit its master. The first half is read from the
2712-
server's effective permissions for the caller (`/auth/me/permissions`); the
2709+
`master_detail` field names its master, required or optional): editing,
2710+
deleting or creating a detail is admitted exactly when the caller holds
2711+
that verb on the detail's object **and** can edit its master. The first
2712+
half is read from the server's effective permissions for the caller
2713+
(`/auth/me/permissions`); the
27132714
second is measured, by an empty update of the master as the caller, which
27142715
runs every write gate the master has — for a master that is itself
27152716
controlled by its parent, the gate above it too. The writes go through the
@@ -2726,9 +2727,15 @@ os verify --dashboard-windows # Also prove each dashboard date
27262727
answer disagrees fails the run, and so does a persona that could not be
27272728
provisioned; a refusal that is not the platform's `403 PERMISSION_DENIED`
27282729
naming the detail and the operation (a rule, a hook's own code, a refusal
2729-
on another object) is reported inconclusive, never proven. An object
2730-
with two `master_detail` fields, none, or a chain at the write gate's depth
2731-
bound is reported `excluded`. A detail two levels below its root master (a
2730+
on another object) is reported inconclusive, never proven. For an optional
2731+
master (its only `master_detail` declared `required: false`) the run also
2732+
proves the records that name no master: such a record is its owner's, so
2733+
its edit or delete is admitted exactly when the caller holds that verb on
2734+
the detail's object **and** owns the record (`owner_id`) or holds Modify All
2735+
on that object, and a create that names no master exactly when the caller
2736+
holds create on it; a null-master record the persona cannot read is not
2737+
probed. An object with two `master_detail` fields, none, or a chain at the
2738+
write gate's depth bound is reported `excluded`. A detail two levels below its root master (a
27322739
line under a quote under an account) is answered the same way: its write is
27332740
admitted exactly when the caller holds the verb on the detail and may update
27342741
the middle master directly, which also needs edit access to the root, and

0 commit comments

Comments
 (0)