Skip to content

Commit 02042be

Browse files
docs: MISSING_REQUIRED_FIELD answers 422 without fields[] on the surviving controlled_by_parent refusals (#8880) (#8963)
`MISSING_REQUIRED_FIELD` is documented as HTTP 400 carrying `fields[]` (`error-handling.mdx`, `error-catalog.mdx`), but `assertControlledByParentWrite` answers `422 MISSING_REQUIRED_FIELD` with no `fields[]` on four declarable shapes, plus a stored-row case on a different path. Documents the exception where it exists rather than moving the wire. Measured on this branch's base rather than taken from the card: - `resolveCbpRelation` sets `omissionRefusedByValidation` to `type === 'master_detail' && required && !readonly && !system`, so only that shape hands over to validation's `400 VALIDATION_FAILED`. - The four surviving shapes are pinned by `controlled-by-parent-sharing.test.ts` (`err.status`/`statusCode` 422). - `MasterReferenceMissingError` declares `code`/`status`/`statusCode`; `mapDataError`'s 4xx passthrough emits `{ error, code, object }`, and `fields[]` is emitted only for the `VALIDATION_FAILED` duck-type — so the absent `fields[]` is confirmed at the door, not assumed. One claim from the card and the #8879 changeset is deliberately NOT repeated: that a publish-time lint bounds these shapes. #8772 is still open and `relationship/master-detail-required` is `severity: 'warning'`, with no rule covering the `readonly`/`system`/fallback-`lookup` shapes — so the docs say these shapes are authorable today. Filed as #8959. No runtime change: the refusal is correct (an unconditional hand-over was measured fail-open) and direction 2 was declined by the maintainer ruling. Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 90197e1 commit 02042be

2 files changed

Lines changed: 82 additions & 3 deletions

File tree

content/docs/api/error-catalog.mdx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,48 @@ substitute. See the [Data API](/docs/api/data-api).
8686

8787
### `MISSING_REQUIRED_FIELD`
8888
**Cause:** A required field was not provided in the request body.
89-
**Fix:** Include the missing field. Check `fields` for the field name.
89+
**Fix:** Include the missing field. Check `fields` for the field name — **except** on the
90+
`controlled_by_parent` master-reference paths below, where the status is `422` and no
91+
`fields` array is sent; read the field name out of the message there.
9092
**Retry:** `no_retry`
9193

94+
<Callout type="warn">
95+
**`MISSING_REQUIRED_FIELD` is `400` with one exception: an absent `controlled_by_parent`
96+
master reference answers `422`, without `fields`.**
97+
98+
An object whose `sharingModel` is `controlled_by_parent` derives its access from a master
99+
record, so the gate authorizing writes to it resolves that master *before* the executor —
100+
and the executor is where required-field validation runs. Which of the two refuses first
101+
depends on how the master reference is declared. A `master_detail` that is `required` and
102+
neither `readonly` nor `system` reaches validation and answers the documented
103+
`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and
104+
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:
105+
106+
- a `master_detail` with no `required`
107+
- a `master_detail` that is `required` + `readonly`
108+
- a `master_detail` that is `required` + `system`
109+
- a `required` `lookup`, when the object declares no `master_detail`
110+
111+
The full matrix is on the protocol page:
112+
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
113+
114+
An update or delete **by id** whose *stored* master reference is null answers the same
115+
`422`, whatever the declaration — the caller sent no such field, so nothing could be named
116+
in `fields` and no payload would fix it.
117+
118+
The refusal itself is correct and is not going to be relaxed: on those four shapes
119+
required-field validation does not fire (it skips `system` and `readonly` fields before its
120+
required check, and never fires on a field that is not `required`), so the gate is the only
121+
thing standing between the request and a detail record with a null master reference — which
122+
the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving
123+
a record readable by nobody.
124+
125+
These shapes are **authorable today**: lint reports a `master_detail` without `required` as
126+
a warning only, and does not report the `readonly`, `system`, or fallback-`lookup` shapes at
127+
all. **Branch on `code`, treat `fields` as optional, and read the status off the response**
128+
rather than deriving it from the table at the end of this page.
129+
</Callout>
130+
92131
### `INVALID_FORMAT`
93132
**Cause:** Field value does not match the expected format (e.g., invalid email, wrong date format).
94133
**Fix:** Ensure the value matches the field's `format` constraint or built-in type validation.
@@ -632,6 +671,7 @@ async function handleApiCall() {
632671
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
633672
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
634673
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` |
674+
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
635675
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
636676
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR`, `TIMEOUT` |
637677
| 502 | `external` | `EXTERNAL_SERVICE_ERROR`, `INTEGRATION_ERROR` |

content/docs/protocol/kernel/error-handling.mdx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ ObjectStack uses standard HTTP status codes:
9090
| **403** | Forbidden | Authenticated but insufficient permissions |
9191
| **404** | Not Found | Resource doesn't exist |
9292
| **409** | Conflict | Resource already exists or version mismatch |
93-
| **422** | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation) |
93+
| **422** | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation); also an absent `controlled_by_parent` master reference — see [`MISSING_REQUIRED_FIELD`](#missing_required_field) |
9494
| **429** | Too Many Requests | Rate limit exceeded |
9595
| **500** | Internal Server Error | Server-side error |
9696
| **503** | Service Unavailable | Server overloaded or maintenance |
@@ -244,7 +244,7 @@ if (error.code === 'VALIDATION_ERROR') {
244244
```
245245

246246
#### `MISSING_REQUIRED_FIELD`
247-
**HTTP Status:** 400
247+
**HTTP Status:** 400 — with one documented exception, which answers **422** (see below)
248248
**Meaning:** Required field is missing
249249

250250
**Example:**
@@ -262,6 +262,45 @@ if (error.code === 'VALIDATION_ERROR') {
262262
}
263263
```
264264

265+
**Exception — an absent `controlled_by_parent` master reference answers 422 with no `fields`.**
266+
An object whose `sharingModel` is `controlled_by_parent` derives its access from a master
267+
record, so the gate that authorizes writes to it must resolve that master *before* the
268+
executor runs — and the executor is where required-field validation lives. When the master
269+
reference is absent, whichever of the two refuses first decides the envelope, and that
270+
depends on how the reference is declared:
271+
272+
| Master reference declared as | Refused by | Status | `code` | `fields` |
273+
|---|---|:---:|---|:---:|
274+
| `master_detail` + `required`, not `readonly`/`system` | required-field validation | 400 | `VALIDATION_FAILED` | yes |
275+
| `master_detail` with no `required` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
276+
| `master_detail` + `required` + `readonly` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
277+
| `master_detail` + `required` + `system` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
278+
| a `required` `lookup`, when the object declares no `master_detail` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
279+
280+
Only the first row is the documented 400 shape. On the other four, messages are prefixed
281+
`[Security] Missing master reference:` and name the object and the field, but no `fields`
282+
array rides along.
283+
284+
The same `422 MISSING_REQUIRED_FIELD` also answers an update or delete **by id** whose
285+
*stored* master reference is null, whatever the declaration. That is a different path: the
286+
caller supplied no such field, so there is no request field to name in `fields` and no
287+
payload that would fix it.
288+
289+
**Why the gate refuses rather than handing over.** Required-field validation skips
290+
provenance-flagged fields before its required check is reached (`system` and `readonly`
291+
fields are skipped outright) and never fires on a field that is not `required` at all. On
292+
those four shapes the gate is the only thing refusing the write, and letting it through was
293+
measured to create a detail record whose master reference is null — a record the
294+
`controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, so it is
295+
readable by nobody and answers 422 on every later write by id. The refusal is correct; only
296+
its status departs from the rule above.
297+
298+
**These shapes are authorable today.** Publish-time lint reports a `master_detail` without
299+
`required` as a *warning* (`relationship/master-detail-required`), and does not report the
300+
`readonly`, `system`, or fallback-`lookup` shapes at all — so a stack can publish clean and
301+
still reach the 422. Branch on `code`, and read the status off the response rather than
302+
deriving it from this page.
303+
265304
#### `INVALID_FIELD`
266305
**HTTP Status:** 400
267306
**Meaning:** Field value has wrong type

0 commit comments

Comments
 (0)