diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 5e706fc50b..e5230c6c48 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -86,9 +86,48 @@ substitute. See the [Data API](/docs/api/data-api). ### `MISSING_REQUIRED_FIELD` **Cause:** A required field was not provided in the request body. -**Fix:** Include the missing field. Check `fields` for the field name. +**Fix:** Include the missing field. Check `fields` for the field name — **except** on the +`controlled_by_parent` master-reference paths below, where the status is `422` and no +`fields` array is sent; read the field name out of the message there. **Retry:** `no_retry` + +**`MISSING_REQUIRED_FIELD` is `400` with one exception: an absent `controlled_by_parent` +master reference answers `422`, without `fields`.** + +An object whose `sharingModel` is `controlled_by_parent` derives its access from a master +record, so the gate authorizing writes to it resolves that master *before* the executor — +and the executor is where required-field validation runs. Which of the two refuses first +depends on how the master reference is declared. A `master_detail` that is `required` and +neither `readonly` nor `system` reaches validation and answers the documented +`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and +answer `422 MISSING_REQUIRED_FIELD` with no `fields`: + +- a `master_detail` with no `required` +- a `master_detail` that is `required` + `readonly` +- a `master_detail` that is `required` + `system` +- a `required` `lookup`, when the object declares no `master_detail` + +The full matrix is on the protocol page: +[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field). + +An update or delete **by id** whose *stored* master reference is null answers the same +`422`, whatever the declaration — the caller sent no such field, so nothing could be named +in `fields` and no payload would fix it. + +The refusal itself is correct and is not going to be relaxed: on those four shapes +required-field validation does not fire (it skips `system` and `readonly` fields before its +required check, and never fires on a field that is not `required`), so the gate is the only +thing standing between the request and a detail record with a null master reference — which +the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving +a record readable by nobody. + +These shapes are **authorable today**: lint reports a `master_detail` without `required` as +a warning only, and does not report the `readonly`, `system`, or fallback-`lookup` shapes at +all. **Branch on `code`, treat `fields` as optional, and read the status off the response** +rather than deriving it from the table at the end of this page. + + ### `INVALID_FORMAT` **Cause:** Field value does not match the expected format (e.g., invalid email, wrong date format). **Fix:** Ensure the value matches the field's `format` constraint or built-in type validation. @@ -632,6 +671,7 @@ async function handleApiCall() { | 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` | | 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` | | 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` | +| 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 | | 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` | | 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR`, `TIMEOUT` | | 502 | `external` | `EXTERNAL_SERVICE_ERROR`, `INTEGRATION_ERROR` | diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 8a35726fa3..c982e9af71 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -90,7 +90,7 @@ ObjectStack uses standard HTTP status codes: | **403** | Forbidden | Authenticated but insufficient permissions | | **404** | Not Found | Resource doesn't exist | | **409** | Conflict | Resource already exists or version mismatch | -| **422** | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation) | +| **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) | | **429** | Too Many Requests | Rate limit exceeded | | **500** | Internal Server Error | Server-side error | | **503** | Service Unavailable | Server overloaded or maintenance | @@ -244,7 +244,7 @@ if (error.code === 'VALIDATION_ERROR') { ``` #### `MISSING_REQUIRED_FIELD` -**HTTP Status:** 400 +**HTTP Status:** 400 — with one documented exception, which answers **422** (see below) **Meaning:** Required field is missing **Example:** @@ -262,6 +262,45 @@ if (error.code === 'VALIDATION_ERROR') { } ``` +**Exception — an absent `controlled_by_parent` master reference answers 422 with no `fields`.** +An object whose `sharingModel` is `controlled_by_parent` derives its access from a master +record, so the gate that authorizes writes to it must resolve that master *before* the +executor runs — and the executor is where required-field validation lives. When the master +reference is absent, whichever of the two refuses first decides the envelope, and that +depends on how the reference is declared: + +| Master reference declared as | Refused by | Status | `code` | `fields` | +|---|---|:---:|---|:---:| +| `master_detail` + `required`, not `readonly`/`system` | required-field validation | 400 | `VALIDATION_FAILED` | yes | +| `master_detail` with no `required` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** | +| `master_detail` + `required` + `readonly` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** | +| `master_detail` + `required` + `system` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** | +| a `required` `lookup`, when the object declares no `master_detail` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** | + +Only the first row is the documented 400 shape. On the other four, messages are prefixed +`[Security] Missing master reference:` and name the object and the field, but no `fields` +array rides along. + +The same `422 MISSING_REQUIRED_FIELD` also answers an update or delete **by id** whose +*stored* master reference is null, whatever the declaration. That is a different path: the +caller supplied no such field, so there is no request field to name in `fields` and no +payload that would fix it. + +**Why the gate refuses rather than handing over.** Required-field validation skips +provenance-flagged fields before its required check is reached (`system` and `readonly` +fields are skipped outright) and never fires on a field that is not `required` at all. On +those four shapes the gate is the only thing refusing the write, and letting it through was +measured to create a detail record whose master reference is null — a record the +`controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, so it is +readable by nobody and answers 422 on every later write by id. The refusal is correct; only +its status departs from the rule above. + +**These shapes are authorable today.** Publish-time lint reports a `master_detail` without +`required` as a *warning* (`relationship/master-detail-required`), and does not report the +`readonly`, `system`, or fallback-`lookup` shapes at all — so a stack can publish clean and +still reach the 422. Branch on `code`, and read the status off the response rather than +deriving it from this page. + #### `INVALID_FIELD` **HTTP Status:** 400 **Meaning:** Field value has wrong type