Skip to content

Commit f7cd580

Browse files
fix(plugin-hono-server): a Hono door's 409 DELETE_RESTRICTED, and every structured refusal, carries RestServer's arm fields (#23587) (#23616)
Part of #23587 Clause-②: no This lands the card's three structured fields on both Hono doors, under one rule that covers every arm of `RestServer`'s classification. It also corrects the published error pages this answer changes (patch rounds 1 and 2, the seat's reviews `6185187566` and `6186145904`). What stays on the card is one location, folded there as its remainder by the seat's pointer `6185197160`: `declaredEnvelopeForThrow` answers the engine's `DuplicateRecordError` as `409 DUPLICATE_RECORD` on both Hono doors, where `RestServer` answers `UNIQUE_VIOLATION` with `developerMessage`, `field` and `object`. The governing text is the maintainer ruling recorded at commit `65846bc46`: one wire spelling on every route. This PR's agreement rule leaves that code alone on purpose. ## What this does The engine refuses the delete of a record that a `deleteBehavior: 'restrict'` lookup still references with `409 DELETE_RESTRICTED`. `RestServer`'s single-record `DELETE` answers it through `mapDataError`, whose first step is `structuredCodeAnswer` (`@objectstack/types`). That arm carries the engine's `developerMessage`, `dependentObject`, `dependentCount` and the route's `object` beside the code. Both Hono doors answered the same throw with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` (its `dispatch()` catch-all, through `thrownJson`) and a route mounted on `HonoHttpServer` (`wrap()`, and the transport `onError` seam). All of them render through `declaredEnvelopeForThrow` in `packages/plugins/plugin-hono-server/src/adapter.ts`. Measured on a real `createHonoApp` over the real engine and protocol (SQLite WASM), at `16abea884`: - FROM `409 {"success":false,"error":{"code":"DELETE_RESTRICTED","message":"This Parent is still referenced by 1 Child record(s) through “Parent”. Delete or reassign them first."}}` - TO the same, plus `"details":{"developerMessage":"Cannot delete parent (ID): 1 dependent child record(s) reference it via parent. Delete or reassign them first, or set deleteBehavior:'cascade' on child.parent.","dependentObject":"child","dependentCount":1}` PR #23582 added a helper beside the recogniser that read the arm for `CONCURRENT_UPDATE` only. This PR widens it from one code to every arm that agrees with the envelope already decided, and renames it from `declaredConflictContext` to `declaredArmContext`, since it no longer reads only conflicts: - **The arm must agree on status and code.** It can add context to the envelope, never change it. An arm that answers a different code is not consulted. - **Everything the arm's body carries is forwarded except three keys:** its own sentence and code (the envelope already carries them as `message` and `code`), and `currentRecord`, which stays held by name (#23586, unchanged from PR #23582). - ⛔ No field and no code is listed. Each field appears only when the producer stated it, by the arm's own rule, so a producer's narrowing passes through as it stands. The engine withholds `dependentCount` when the caller's own identity would not see the same rows (`dependentCountIsDisclosable`), and then the door sends no count either. - **Where they land:** `error.details`, as PR #23582 placed `currentVersion`. On the REST wire every arm field is a top-level member of the flat body, and that body has no `details`. On the wrapped envelope the only declared context slot is `ApiErrorSchema.details`, since `developerMessage`, `dependentObject` and `dependentCount` are not `ApiErrorSchema` members. `@objectstack/client` reads `error.details = errorBody.details ?? errorBody.error.details ?? errorBody` (`packages/client/src/index.ts`), so a client sees the same keys under `err.details` from either door. ⛔ No third spelling of the refusal body. Status, `code` and `message` do not move on any answer. The REST door's answer is unchanged. `@objectstack/hono`'s source is unchanged too: `createHonoApp` renders through `declaredEnvelopeForThrow`. ## Every arm, checked Each arm of `structuredCodeAnswer` is listed below, with what `RestServer` sends and what the Hono doors send before and after. Readings marked "measured" come from a real `createHonoApp` at `16abea884` (before) and `d1a39b380` (after). The rest come from the producer-shaped rows in the unit file and the code read. | code (status) | `RestServer` (`mapDataError`) | Hono doors before | Hono doors after | not forwarded, and why | |---|---|---|---|---| | `DELETE_RESTRICTED` (409) | `developerMessage`, `dependentObject`, `dependentCount`, `object` | none (measured) | `developerMessage`, `dependentObject`, `dependentCount` (measured) | `object`: the arm takes it from the route only | | `CONCURRENT_UPDATE` (409) | `currentVersion`, `currentRecord`, `object` | `currentVersion` | `currentVersion` (unchanged) | `currentRecord`: held, #23586. `object`: route only | | `METADATA_CONFLICT` (409) | `currentVersion`, `object` | none | `currentVersion`, on a route mounted on `HonoHttpServer` | `object`: route only. On `createHonoApp` the `/meta` save is answered by the dispatcher's own `errorFromThrown`, so it never reaches the recogniser. Measured unchanged there; see Acceptance notes (#22222) | | `INVALID_FIELD` (400) | `field`, `object` | none (measured) | `field`, `object` (measured) | none | | `OBJECT_NOT_FOUND` (404) | `object` | none (measured) | `object` (measured) | none. The arm's fixed sentence is not taken: the envelope keeps the thrown `message` | | `FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE` (403) | `object` | none | `object` | none | | `VALIDATION_FAILED` (400) | `fields`, `object` | `fields` (the resolver's) | `fields`, the same array | `object`: route only. `validationFailureDetails` and the arm read `fields` alike, so the merge is byte-identical | | `DUPLICATE_RECORD`, answered by REST as `UNIQUE_VIOLATION` (409) | `developerMessage`, `field`, `object` | none, code `DUPLICATE_RECORD` (measured) | unchanged (measured) | the arm answers a different code, so it is not consulted. This is the card's remainder (opening paragraph) | | `ERR_DATASOURCE_UNAVAILABLE` (503) | `datasource`, `reason`, `object` | the recogniser declines | unchanged | its one producer (`DatasourceUnavailableError`, `@objectstack/objectql`) declares no `status`, so the recogniser never answers it | | `OBJECT_API_DISABLED`, `OBJECT_API_METHOD_NOT_ALLOWED` | `object`, `allowed` | none | unchanged | the arm fires only when it is given a route object that differs from the error's own, and the recogniser gives none | **System-context reads (#23586's question, asked of every arm).** `currentRecord` is the only field read with a system context and left unnarrowed. Every other field is one of these: - a version token (`currentVersion`); - an API name (`object`, `dependentObject`, `field`); - the caller's own input; - a sentence that names the same API names (`developerMessage`). `DELETE_RESTRICTED`'s dependents probe is elevated. But the engine discloses the count, in `dependentCount` and in `developerMessage`, only when the caller's own identity would have read the same rows (`dependentCountIsDisclosable`, `@objectstack/objectql`). It names the dependent object unconditionally, by that ruling's constraint 2. `RestServer` already sends all of it to the same caller. So `currentRecord` stays the one exclusion, by name. The helper's docblock says that an arm which grows such a field must be held there by name in the same way. **Sandboxed producers.** A `SandboxError` carries only `code`, `fields`, `status`, `userMessage` and `innerMessage` (`runtime/src/sandbox/quickjs-runner.ts`). So no arm adds a field from one. `VALIDATION_FAILED`'s `fields` is the resolver's own array, and `INVALID_FIELD` and `OBJECT_NOT_FOUND` decline a sandbox origin themselves. ## What stays off these doors - **`object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT`.** Those arms add the object the ROUTE named (`mapDataError(error, req.params.object)`). `declaredEnvelopeForThrow` has no route, so it asks the arm without one, as PR #23582 recorded for `CONCURRENT_UPDATE`. The caller named that object in its own URL. ⛔ It is not substituted from the error's own `object`. The engine's cascade recurses through the public `delete(childName, …)`, so a restrict deeper in a cascade names the child it was deleting, not the object the route addressed, and REST would answer differently. **Decided** by the seat (review `6185187566`, option A): the field is not owed at these doors. This follows the family precedent, PR #23582, which landed `CONCURRENT_UPDATE` without it, and #23531's Landed comment `6183626856`, which names `currentRecord` as that card's only remainder. - **`currentRecord`:** held, #23586, as before. The two absence pins from PR #23582 are unchanged and green. ## Tests - **Pins.** Both files keep their existing controls, and the #23531 blocks are unchanged. - `packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts`, the new `[#23587]` block, 14 rows on direct-mount routes: - `DELETE_RESTRICTED` with the count, with the count withheld, and in the subflow guard's shape, which states no field and keeps the two-key envelope; - `METADATA_CONFLICT` with a token and with `null`; - `INVALID_FIELD`, `OBJECT_NOT_FOUND`, and the five 403 access codes; - two guard rows: an arm whose status, or whose code, differs from the declared one is not consulted. - Each refusal row asserts status, `code` and `error.details` exactly. - `packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts`, the new `[#23587]` block, 3 rows on a real `createHonoApp`: - a restricted `DELETE` (account and contact, `restrict`): `error.details` equals the `developerMessage`, `dependentObject` and `dependentCount` of the throw the engine really raised, read off the `deleteData` call. The row also asserts that throw stated a string `developerMessage`, so the equality cannot hold between two absences, and that the parent row is still stored; - `GET /data/task?nosuchfield=1`: `400 INVALID_FIELD` with `{ field, object }`; - `GET /data/ghost`: `404 OBJECT_NOT_FOUND` with `{ object }`. - **Red before the fix** (pins only, `90574de90`, on the unfixed recogniser): unit `Tests 11 failed | 26 passed (37)`, conformance `Tests 3 failed | 15 passed (18)`. Every red row failed at its `error.details` assertion with `expected undefined to deeply equal …`. The three guard rows pass on the unfixed tree by construction; they are the regression half. - **Ablation**, on `d1a39b380`. Two legs, each run with `node scripts/ablation-replace.mjs` in wrap mode, with an outer `trap … EXIT INT TERM` restore against the absolute path. Each leg rebuilt `@objectstack/plugin-hono-server`, and `ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.mjs` before either suite ran. - **Leg A** narrowed the helper back to one code. The anchor ``const arm = structuredCodeAnswer(thrown);`` became a read guarded on `resolved.code === 'CONCURRENT_UPDATE'`, carrying the marker `ABLATION_23587_ONE_CODE` (blob `7fd1f090b0` to `11a3ca30af`). Every new `[#23587]` refusal row went RED, and the `[#23531]` rows stayed green: unit `Tests 11 failed | 26 passed (37)`, conformance `Tests 3 failed | 15 passed (18)`, each at `expected undefined to deeply equal …`. - **Leg B** dropped the held field. The anchor ``&& key !== 'currentRecord'),`` became a key no body carries, `ABLATION_23587_HELD_FIELD` (blob to `1f5ac3cbb6`). Exactly the four `currentRecord` absence pins went RED: unit `Tests 2 failed | 35 passed (37)`, `expected { …(2) } to deeply equal { Object (currentVersion) }`; conformance `Tests 2 failed | 16 passed (18)`, `to not have property "currentRecord"`. The conformance body then carried the system-context row the real protocol states (`organization_id`, `owner_id`, `created_by` and the rest). That is the disclosure the exclusion holds back. - **Restore:** the blob equals HEAD (`7fd1f090b0`), `git diff HEAD` is empty and `git status --porcelain` is clean. After a rebuild, preflight `--absent` found both markers gone from all 6 built files, with a clean tree. Both pin files were green again: unit `37 passed (37)`, conformance `18 passed (18)`. - **Packages**, at `d1a39b380`, after a rebuild of `@objectstack/plugin-hono-server` (the conformance file reads its `dist/`): - `pnpm --filter @objectstack/plugin-hono-server typecheck` (tsc, `tsconfig.typecheck.json`, `check:test-typecheck` OK, 0 debt) and `test`: `Test Files 29 passed (29)`, `Tests 354 passed (354)`. - `pnpm --filter @objectstack/http-conformance typecheck` (`check:test-typecheck` OK, ledger unchanged) and `test`: `Test Files 13 passed (13)`, `Tests 142 passed (142)`. - **Patch rounds 1 and 2 (docs only, heads `83c418436` and `9675127f3`).** No package file changed since `d1a39b380`, so the package suites above stand. The docs readings were taken with scratch files that were not committed: R1 on the `createHonoApp` rig, and R2 on one kernel, comparing a composed host (`@objectstack/rest`'s routes registered, then `createHonoApp` mounted) with `createHonoApp` alone. See "Docs corrected (patch rounds 1 and 2)". - `@objectstack/hono`'s own suite is not run locally: its source is untouched, and its vitest config stubs `@objectstack/runtime`. The composed-host pins above cover its door. It is declared to CI. ## Gates Local, at the final head `9675127f3`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run with no paths, derived **95** commands from the 7 changed paths (merge base `16abea884`); the 3 docs pages add 29 families to round 0's 66. All 95 ran, each exit code captured before any pipe. - 93 exited 0 on the first pass. - `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads` first exited 3, `PREREQUISITE NOT MET`: the recreated worktree had no `packages/client-react/dist` and no workspace `dist/`. - Under the lock, after a cache-backed `pnpm turbo run build --concurrency=2` (`76 successful`, `75 cached`), both measured exit 0: `263 prose examples type-check across 3 surface(s)` and `110 published require entry point(s) across 68 package(s) load`. - `--ran` reconciliation: `95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero). - The docs gates on the touched pages, among them, all exit 0: `check:doc-anchors`, `@objectstack/spec check:docs`, `check:docs-transcript-drift`, `check:corpus-claim-drift`, `check:docs-redirects`, `check:docs-single-h1`, `check:docs-spec-enumerations`, `check:doc-frontmatter`, `check:docs-section-name` and `check:section-landing-index`. - `pnpm check:route-envelope` and `pnpm check:dispatcher-error-vocabulary` (derived), and `pnpm check:durability-log-level` (run separately): exit 0. - The four artifact-roster gates under touched directories, all exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`. - ⚠️ The derivation reports a stale tree: this branch is 16 commits behind `origin/main` (`7c6b7f4a3`), and 11 gate files changed across that range. So the local run read this branch's copies of those scripts. None of those commits touches the 7 changed paths, and `git merge-tree --write-tree HEAD origin/main` is clean, so the branch is not merged (after a PR opens, `main` is merged only on a conflict, a main-caused red or a queue removal). CI runs the merge ref, which carries the current scripts. - CI on `9675127f3`, read once: 41 check-runs, 36 success, 5 skipped, none failed or in progress. - Round 1 (`83c418436`) gave the same 95-family result. Round 0 (code head `d1a39b380`): 66 derived, 66 run, 0 NOT-MEASURED; `eslint --no-inline-config` over the two touched packages exit 0. The patch rounds change no lintable source. ## Changeset `.changeset/23587-hono-structured-arm-fields.md`: `@objectstack/plugin-hono-server` `patch`. `@objectstack/hono` is not named, because its source does not change, and both packages are in one fixed version group. The body gives FROM and TO for `DELETE_RESTRICTED`, names the other arms the rule now covers, and says which two fields stay off and why. ## Docs corrected (patch rounds 1 and 2) Head `9675127f3`: round 1's commit `83c418436`, then round 2's `9675127f3`, which scopes the sentences that assumed every `createHonoApp` host serves `/data` through the dispatcher (the seat's review `6186145904`). Line numbers are given on that head, with the `origin/main` lines in brackets; those three files are unchanged on `main` since the merge base. Readings: - **R1:** a real `createHonoApp` host alone, over the real engine and protocol (SQLite WASM), at `d1a39b380`, the code these rounds do not change. A scratch file, not committed, with `RestServer`'s body for each request computed from the same thrown error by `mapDataError`. - **R2:** one kernel (engine, `HonoServerPlugin`, `createRestApiPlugin`) at `9675127f3`. Two hosts were measured: - the hosted composition: `@objectstack/rest`'s routes registered on the Hono app, then `createHonoApp` mounted on it with `route('/', …)` (the `ALL /api/v1/*` catch-all present); - `createHonoApp` alone, over the same kernel. A scratch file, not committed. - **U:** the `[#23587]` unit pins on a `HonoHttpServer` route. - **6110136480:** the maintainer's hosted measurement on #22222, for `/meta` on the hosted composition (flat, from the REST door). R1 measured, per code, on the Hono door and then the REST twin: - `DELETE_RESTRICTED`: nested, `details` `{developerMessage, dependentObject, dependentCount}` / flat with those three and `object`. - `CONCURRENT_UPDATE`: nested, `details` `{currentVersion}` / flat with `currentVersion`, `currentRecord`, `object`. - `INVALID_FIELD`: nested, `{field, object}` / flat with both. - `OBJECT_NOT_FOUND`: nested, `{object}`, message `Object 'ghost' not found` / flat, `error` `Object 'ghost' is not registered`. - `VALIDATION_FAILED` (a missing required field): nested, `{fields}` / flat with `fields` and `object`. - A duplicate insert: nested `DUPLICATE_RECORD`, no `details` / flat `UNIQUE_VIOLATION` with `developerMessage`, `field`, `object`. - None of the six Hono bodies carries `httpStatus`. R2 measured, on the hosted composition and then on `createHonoApp` alone: - the same restricted `DELETE /api/v1/data/account/ID`: `409` **flat** `{error, code: DELETE_RESTRICTED, developerMessage, dependentObject, dependentCount: 1, object: "account"}` / `409` **nested** with those three under `error.details`; - `GET /api/v1/data/account?nosuchfield=1`: `400` **flat** `INVALID_FIELD` with `field` and `object` / `400` **nested** with `{field, object}` under `error.details`. `content/docs/protocol/kernel/error-handling.mdx`: | Lines (`main`) | Change | Reading | |---|---|---| | `:75`-`:77` (`:75`-`:78`) | Corollary 1 keeps `UNIQUE_VIOLATION` "always flat" and drops `DELETE_RESTRICTED` / `INVALID_FIELD` from it | R1 (`UNIQUE_VIOLATION` answered by no Hono door) | | `:81`-`:84` (`:82`) | Corollary 2 gains `DELETE_RESTRICTED`, `INVALID_FIELD`, `OBJECT_NOT_FOUND`: flat on the `@objectstack/rest` `/data` door, nested on a Hono door with their fields under `error.details` | R1, R2 | | `:107` (`:105`) | "two writers" → "three writers" | R1 | | `:113`-`:114` (new) | the third writer, `declaredEnvelopeForThrow`, the Hono doors' renderer | R1, U | | `:133` (`:129`) | `error.httpStatus`: the Hono doors' renderer never writes it | R1 (0 of 6 bodies) | | `:134`-`:137` (`:130`-`:132`) | `error.details`: "and nowhere else" removed; the Hono doors put the per-condition siblings there | R1, U | | `:183` (`:178`) | the `{base}/data/*` row is scoped to the `@objectstack/rest` door | R1, R2 | | `:193` (new; round 2 scoped) | route-family row: a declared refusal thrown out of the dispatcher on a `createHonoApp` host, with `/data` included only where no `@objectstack/rest` route is registered ahead of its catch-all; or out of a `HonoHttpServer` route. Nested | R1, R2, U | | `:196`-`:206` (`:190`-`:194`; round 2) | the split-by-door callout now covers `/data` as well as `/meta`. A `createHonoApp` host can be either: REST routes registered before the mount (the hosted composition) answer flat on the REST door; with none ahead of the catch-all, `/data` and `/meta` answer through the dispatcher, nested | R2 (`/data`), 6110136480 (`/meta`) | | `:218`-`:261` (new; round 2 scoped) | `### Hono doors`: who renders (the `/data` refusals leave the dispatcher only on a host whose catch-all serves `/data`, with a pointer to the table), the `DELETE_RESTRICTED` example (captioned "whose catch-all serves `/data`"), the per-code `details` table, and what a Hono door never sends (`httpStatus`, `currentRecord`, a route-taken `object`; the thrown `message`) | The example and the `DELETE_RESTRICTED`, `CONCURRENT_UPDATE`, `INVALID_FIELD`, `OBJECT_NOT_FOUND`, `VALIDATION_FAILED` rows: R1, R2. The five 403 rows and "`dependentCount` when the flat door would send it": U | | `:406`-`:407` (`:350`) | `VALIDATION_FAILED` envelope: flat on the rest door, nested on a Hono door (in-place, below) | R1 | | `:409`-`:410` (`:352`-`:353`) | `VALIDATION_FAILED`: `fields` is `error.details.fields` on a Hono door (in-place) | R1 | | `:598`-`:600` (`:541`-`:542`) | `INVALID_FIELD` envelope: nested on a Hono door, `error.details.field` / `.object` | R1, R2 | | `:655`-`:656` (`:597`) | `OBJECT_NOT_FOUND`: nested on a Hono door, `object` in `error.details` | R1 | | `:695`-`:697` (`:636`-`:637`) | `DELETE_RESTRICTED` envelope: nested on a Hono door, the three fields in `error.details`, no `object` | R1, R2 | | `:1052`-`:1054` (`:992`-`:993`) | `readError` reads `err.details?.fields` on a nested body (in-place) | R1 | | `:1103`-`:1105` (`:1042`-`:1043`) | field-validation guidance: `error.details.fields` on a Hono door; "no `details.fields` bag on either envelope" removed (in-place) | R1 | `content/docs/api/wire-format.mdx`: | Lines (`main`) | Change | Reading | |---|---|---| | `:328` (`:328`; round 2 scoped) | "Data route errors" scoped to the kernel REST server. A `createHonoApp` host answers nested when its catch-all serves `/data`; one that registers the REST routes before it mounts `createHonoApp` (the hosted composition) answers flat | R1, R2 | | `:485`-`:507` (new; round 2 caption) | `### Hono hosts nest the extras`: nested, no `httpStatus`; the classification's extras in `error.details`, with `currentRecord` and a route-taken `object` not sent; the `CONCURRENT_UPDATE` example, on a host "whose catch-all serves `/data`" | R1 | `content/docs/api/index.mdx`: | Lines (`main`) | Change | Reading | |---|---|---| | `:221`-`:222` (new; round 2 scoped) | On a `createHonoApp` host or a `HonoHttpServer` route, a declared refusal is nested **without** `httpStatus`, with the classification's fields in `details` except `currentRecord` and a route-taken `object`. The `/data` refusals are among them only where the catch-all serves `/data`; the hosted composition answers those flat | R1, R2 | **The bounded in-place rule, for `VALIDATION_FAILED`.** This PR leaves that code's Hono-door body byte-identical. Four lines were false in the same way and are corrected: `:406`-`:407`, `:409`-`:410`, `:1052`-`:1054` and `:1103`-`:1105`. Each condition was checked: 1. **Same defect class:** a published statement that a refusal is never nested, or has no `details`, which is false at the Hono doors. 2. **Mechanical, with the shape pinned:** R1 measured `error.details.fields`, and `handler-throw-declared-envelope.test.ts` already pins it on a direct-mount route. 3. **No other claim holds the file:** the seat's pointer `6185197160` read all 15 open PRs. 4. **Same gate family:** the docs gates on the same page, with no new verification surface. **Read and left unchanged:** - `content/docs/api/error-handling-server.mdx:10`: it is scoped to the kernel REST server, and true there. - `content/docs/api/data-api.mdx:45`-`:52`: the `INVALID_FIELD` example is the REST server's body and claims no exclusivity. - `content/docs/protocol/objectql/types.mdx:717` ("the response carries `dependentObject` and `dependentCount`"): true on both doors. - `error-handling.mdx:1399`-`:1401` (`DELETE_RESTRICTED` withholding): true on both doors. - The `RECORD_NOT_FOUND` callout at `:642`-`:658`: it names the `/data` door and makes no exclusivity claim. - No published statement names `METADATA_CONFLICT`'s or the five 403 codes' body shape. The 403 codes appear only in status tables (`permissions/attachments-access.mdx`). - ⛔ `content/docs/releases/` is untouched. ## Found, not fixed here - **The published duplicate-record statements**, which this PR does not change. The bounded in-place rule fails its second condition here: the shape is not settled, because the card's remainder changes the code these doors answer. Carrier: #23587's remainder. On a `createHonoApp` host a duplicate insert was measured as `409 {"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'task': …"}}`, at `16abea884` and again at `d1a39b380`. These lines say otherwise, and they are left as they stand: - `content/docs/protocol/kernel/error-handling.mdx:668` (`:609` on `origin/main`): `UNIQUE_VIOLATION` "flat … so it is never nested". - `:680`-`:683` (`:621`-`:624` on `origin/main`): "every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling never crosses HTTP". - `content/docs/api/error-catalog.mdx:507`-`:510`: the same sentence. - `:75`-`:76` of the corrected corollary keeps `UNIQUE_VIOLATION` "always flat". That holds for every reading taken: no Hono door answered `UNIQUE_VIOLATION`. It moves with the remainder. ## Acceptance notes - `createHonoApp`'s `/meta` save answers `409 METADATA_CONFLICT` through the runtime dispatcher's own writer (`HttpDispatcher.errorFromThrown`), without `currentVersion`. Measured at `16abea884`, on a host with no `@objectstack/rest` route ahead of the catch-all. This is #22222's question, closed `not_planned` on the maintainer's hosted measurement `6110136480`: a hosted `PUT /meta` reaches `RestServer`, not the catch-all. Its triage `6056494314` set the build shape for the catch-all case: a declared `ApiErrorSchema` member in `packages/spec`, not this file. Not refiled. - The #23531 card's remainder (`currentRecord`) now lives in `declaredArmContext`, the renamed helper. The exclusion line, its comment pointing at #23586 and the two absence pins are unchanged. The card's Landed comment names the old helper name. - `OBJECT_NOT_FOUND`'s sentence differs between the doors: `RestServer`'s arm composes `Object 'ghost' is not registered`, and these doors keep the thrown `Object 'ghost' not found`. This PR does not move `message`. - The two REST doors differ on a declared 5xx arm: `mapDataError` takes `ERR_DATASOURCE_UNAVAILABLE`'s arm and `resolveErrorResponse` does not. No producer declares that status today, so the Hono doors never meet it. If one ever does, this rule matches `mapDataError`. - objectui has no reader of `dependentObject` or `dependentCount` (`git grep` at `28fb2a0`: 0 hits). The reach is any API client of a `createHonoApp` or `HonoHttpServer` host, through `err.details`. --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a96bf8 commit f7cd580

7 files changed

Lines changed: 464 additions & 72 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
fix(plugin-hono-server): a structured refusal answered by either Hono door carries the fields the standalone REST server's answer carries, such as `409 DELETE_RESTRICTED`'s `developerMessage`, `dependentObject` and `dependentCount` (#23587)
6+
7+
Deleting a record that a `deleteBehavior: 'restrict'` lookup still references is refused by the engine with `409 DELETE_RESTRICTED`. The standalone REST server's answer carries the engine's `developerMessage` (the API names and the `deleteBehavior: 'cascade'` remedy), `dependentObject` and, when the caller may see it, `dependentCount`. The two Hono doors answered the same refusal with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`.
8+
9+
- FROM `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…"}}`
10+
- TO `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…","details":{"developerMessage":"…","dependentObject":"…","dependentCount":1}}}`
11+
12+
The same rule now covers every refusal the REST server answers with structured fields, not only `409 CONCURRENT_UPDATE`'s `currentVersion`: `400 INVALID_FIELD` carries `field` and `object`, `404 OBJECT_NOT_FOUND` carries `object`, the `403` capability and access refusals (`FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`) carry `object`, and a `409 METADATA_CONFLICT` thrown out of a direct-mount route carries `currentVersion` (on `createHonoApp` the `/meta` save is answered by the dispatcher itself, which this does not change). The fields ride `error.details`, where `@objectstack/client` reads a wrapped body's context, so `err.details.developerMessage` and its siblings carry them on these hosts too. They are read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so each appears only when the producer stated it. Status, `code` and `message` are unchanged, and a refusal whose classification answers a different status or code (a duplicate insert's `DUPLICATE_RECORD`, which the REST server answers as `UNIQUE_VIOLATION`) gains nothing.
13+
14+
Two fields stay off these doors. `object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT` is the object the request's route named, and these doors answer without a route. `CONCURRENT_UPDATE`'s `currentRecord` stays held until #23586 settles how the protocol reads that row. The REST server's answer is unchanged.
15+
16+
Clause-②: no

‎content/docs/api/index.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ Validation failures additionally include a `fields` array (one entry per invalid
218218
}
219219
```
220220

221+
On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except `currentRecord` and an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras).
222+
221223
Branch on `error.code`, never on `error.httpStatus` — the status answers "what
222224
class of failure" and the code answers "which failure". A branch that has no
223225
code of its own is served a `StandardErrorCode` derived from the status

‎content/docs/api/wire-format.mdx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ Returns metadata for a single object including all fields, relationships, and co
325325

326326
## 7. Error Response Format
327327

328-
Data route errors are returned as a flat envelope: a top-level `error` message string, a machine-readable `code`, and operation-specific extras (`fields`, `object`, `currentVersion`, …). The HTTP status is carried by the response, not duplicated in the body.
328+
Data route errors from the kernel REST server (`@objectstack/rest`) are returned as a flat envelope: a top-level `error` message string, a machine-readable `code`, and operation-specific extras (`fields`, `object`, `currentVersion`, …). The HTTP status is carried by the response, not duplicated in the body. A host built with `createHonoApp` answers the same refusals nested instead when its catch-all serves `/data` — see [Hono hosts nest the extras](#hono-hosts-nest-the-extras). One that registers the kernel REST server's routes on the same Hono app before it mounts `createHonoApp`, the hosted composition, answers them flat, as below: the routes registered first win the match.
329329

330330
### Validation Error — `400 Bad Request`
331331

@@ -482,6 +482,29 @@ no longer reads those legacy spots (#4007 — servers and SDKs ship as one
482482
release group); any code reading them directly should move to `error.code`.
483483
</Callout>
484484

485+
### Hono hosts nest the extras
486+
487+
A host built with `@objectstack/hono`'s `createHonoApp`, and a route mounted on
488+
`@objectstack/plugin-hono-server`'s `HonoHttpServer`, answer a declared refusal that
489+
escapes the handler in the nested envelope above, without `httpStatus`. The extras the
490+
REST door's classification adds (`fields`, `currentVersion`, `dependentObject`, …) ride in
491+
`error.details`; `currentRecord`, and an `object` the flat body takes from the request's
492+
route, are not sent. The concurrent update above, on a `createHonoApp` host whose catch-all serves `/data`:
493+
494+
```json
495+
{
496+
"success": false,
497+
"error": {
498+
"code": "CONCURRENT_UPDATE",
499+
"message": "Record task/tsk_01HQ4A7B9D3F5G8J2K4L was modified by another user (current version 2025-01-20T14:15:00.000Z, expected 2025-01-20T14:10:00.000Z)",
500+
"details": { "currentVersion": "2025-01-20T14:15:00.000Z" }
501+
}
502+
}
503+
```
504+
505+
See [Error handling → Hono doors](/docs/protocol/kernel/error-handling#hono-doors) for
506+
every code that carries `error.details` there.
507+
485508
---
486509

487510
## 8. Batch Operations

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

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,16 @@ universal one.
7272
7373
Two corollaries, and both of them bite:
7474

75-
- **A refusal a writer *builds* always carries that writer's envelope.** `UNIQUE_VIOLATION`,
76-
`DELETE_RESTRICTED` and `INVALID_FIELD` are built by the `/data` door's classification
77-
arms, so they are **always flat**. `RATE_LIMIT_EXCEEDED` is built by the inbound rate
78-
limiter, so it is **always nested**.
75+
- **A refusal a writer *builds* always carries that writer's envelope.** `UNIQUE_VIOLATION`
76+
is built by the `/data` door's classification arms, so it is **always flat**.
77+
`RATE_LIMIT_EXCEEDED` is built by the inbound rate limiter, so it is **always nested**.
7978
- **A refusal that is *thrown* and classified at the boundary carries the envelope of
8079
whichever door caught it.** `PERMISSION_DENIED`, `RESOURCE_NOT_FOUND`,
8180
`SERVICE_UNAVAILABLE` and `INTERNAL_ERROR` are all thrown by shared guards and reach
82-
clients in **either** envelope depending on the route.
81+
clients in **either** envelope depending on the route. So are `DELETE_RESTRICTED`,
82+
`INVALID_FIELD` and `OBJECT_NOT_FOUND`, which the engine and the protocol throw: the
83+
`@objectstack/rest` `/data` door answers them flat, and a [Hono door](#hono-doors)
84+
answers them nested, with their structured fields under `error.details`.
8385

8486
So do not derive the envelope from the code. Derive it from the body you are holding:
8587

@@ -102,12 +104,14 @@ mistake on this page — `body.error.code` is `undefined` against a flat body, a
102104
### Nested envelope — `{ success, error: { … } }`
103105
104106
The envelope [`BaseResponseSchema`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/api/contract.zod.ts)
105-
declares. Written by two writers:
107+
declares. Written by three writers:
106108
107109
- `sendOk` / `sendError` in `packages/types/src/response-envelope.ts` — the shared pair
108110
every converted route module writes through.
109111
- `buildApiError` / `apiErrorResponse` in `packages/runtime/src/error-envelope.ts` — the
110112
runtime HTTP dispatcher's door, and the inbound rate limiter.
113+
- `declaredEnvelopeForThrow` in `@objectstack/plugin-hono-server` — the
114+
[Hono doors](#hono-doors)' renderer for a declared refusal that escapes a handler.
111115
112116
```json
113117
{
@@ -126,10 +130,11 @@ declares. Written by two writers:
126130
- `error.message` — the human-readable message.
127131
- `error.httpStatus` — the status, repeated in the body. Written by the **dispatcher**
128132
writer on every body it builds; no `sendError` call site passes one today, so it is
129-
absent from the route-module bodies.
130-
- `error.details` — optional structured context, present only where the emitting route
131-
passes one. Not a general-purpose bag: it appears on a handful of refusals
132-
(rate limiting, settings validation, partial-delete reports) and nowhere else.
133+
absent from the route-module bodies, and the Hono doors' renderer never writes it.
134+
- `error.details` — optional structured context, present only where the writer has
135+
some. The route modules pass one on a handful of refusals (rate limiting, settings
136+
validation, partial-delete reports). The [Hono doors](#hono-doors) put there the
137+
per-condition siblings their table lists.
133138
- `error.declaredCode` — optional. An author's own `code` when it is **not** a member of
134139
the closed vocabulary, demoted to this sibling rather than overwriting `code`.
135140
- `error.userMessage` — optional. Text a producer marked, at throw time, as addressed to
@@ -175,7 +180,7 @@ none of them.
175180
176181
| Route family | Envelope | Writer |
177182
|---|---|---|
178-
| `{base}/data/*` — CRUD, `query`, `batch`, `createMany` / `updateMany` / `deleteMany`, `clone`, import / export, reports, approvals, sharing rules | **flat** | `packages/rest/src/error-response.ts` |
183+
| `{base}/data/*` on the `@objectstack/rest` door — CRUD, `query`, `batch`, `createMany` / `updateMany` / `deleteMany`, `clone`, import / export, reports, approvals, sharing rules | **flat** | `packages/rest/src/error-response.ts` |
179184
| `{base}/meta/*`, `{base}/ui/*`, discovery and search on the `@objectstack/rest` door, for anything **thrown** out of a handler | **flat** | the same door, via `handleRouteError` |
180185
| `/api/v1/storage/*` | nested | `storage-routes.ts` → `sendError` |
181186
| `/api/settings/*` | nested | `settings-routes.ts` → `sendError` |
@@ -185,13 +190,20 @@ none of them.
185190
| `/api/v1/i18n/*` | nested | `i18n-service-plugin.ts` → `sendError` |
186191
| every route on a host served by the **runtime HTTP dispatcher** (`/meta`, `/auth`, `/ai`, `/automation`, `/packages`, `/ui`, `/security`, `/notifications`, `/analytics`, `/mcp`, `/keys`, `/actions`, `/share-links`, `/i18n`) | nested | `buildApiError` / `apiErrorResponse` |
187192
| the inbound rate limiter, ahead of every route | nested | `inbound-rate-limit.ts` |
193+
| a declared refusal thrown out of the dispatcher on a `createHonoApp` host — `/data` included only where no `@objectstack/rest` route is registered ahead of its catch-all (see the callout below) — or out of any route mounted on `HonoHttpServer` | nested | `declaredEnvelopeForThrow` — see [Hono doors](#hono-doors) |
188194
189195
<Callout type="warn">
190-
**The `/meta` family is split by door, not by path.** A host that serves `/meta` through
191-
the runtime dispatcher answers nested — that is the shape the
192-
[error catalog's `/meta` examples](/docs/api/error-catalog) publish. A host that serves it
196+
**The `/meta` and `/data` families are split by door, not by path.** A host that serves
197+
`/meta` through the runtime dispatcher answers nested — that is the shape the
198+
[error catalog's `/meta` examples](/docs/api/error-catalog) publish — and so does one that
199+
serves `/data` through it (see [Hono doors](#hono-doors)). A host that serves either
193200
through `@objectstack/rest` answers the flat body for the *same* refusal, because the same
194-
thrown error is classified by a different door. Read the body, not the path.
201+
thrown error is classified by a different door. A `createHonoApp` host can be either. One
202+
that registers `@objectstack/rest`'s routes on the same Hono app before it mounts
203+
`createHonoApp` — the hosted composition — answers those paths on the REST door, flat,
204+
because the routes registered first win the match. One with no `@objectstack/rest` route
205+
ahead of the catch-all answers them through the dispatcher, nested. Read the body, not
206+
the path.
195207
</Callout>
196208
197209
<Callout type="info">
@@ -203,6 +215,50 @@ much is left. Write clients against the discriminator above, which is correct fo
203215
conforming bodies and fails loudly rather than silently on the rest.
204216
</Callout>
205217
218+
### Hono doors
219+
220+
Two hosts render a refusal that escapes the handler themselves:
221+
`@objectstack/hono`'s `createHonoApp`, for a throw that leaves the runtime dispatcher (the
222+
`/data` routes' refusals do, on a host whose catch-all serves `/data`; a host that registers
223+
`@objectstack/rest`'s routes ahead of it answers those flat — see
224+
[Which route families answer which](#which-route-families-answer-which)), and `@objectstack/plugin-hono-server`'s `HonoHttpServer`, for
225+
a throw that escapes a route mounted on it. When the throw declares an ADR-0112 status and
226+
a registered code, both answer it in the **nested** envelope, written by
227+
`declaredEnvelopeForThrow`. The per-condition siblings the flat `/data` door carries for
228+
the codes below ride in `error.details` instead.
229+
230+
**Example** — `DELETE /api/v1/data/account/acc_123` on a `createHonoApp` host whose catch-all
231+
serves `/data`:
232+
```json
233+
{
234+
"success": false,
235+
"error": {
236+
"code": "DELETE_RESTRICTED",
237+
"message": "This Account is still referenced by 1 Contact record(s) through “Account”. Delete or reassign them first.",
238+
"details": {
239+
"developerMessage": "Cannot delete account (acc_123): 1 dependent contact record(s) reference it via account. Delete or reassign them first, or set deleteBehavior:'cascade' on contact.account.",
240+
"dependentObject": "contact",
241+
"dependentCount": 1
242+
}
243+
}
244+
}
245+
```
246+
247+
| Code | In `error.details` on a Hono door |
248+
|---|---|
249+
| `DELETE_RESTRICTED` | `developerMessage`, `dependentObject`, and `dependentCount` when the flat door would send it |
250+
| `CONCURRENT_UPDATE` | `currentVersion` |
251+
| `INVALID_FIELD` | `field`, `object` |
252+
| `OBJECT_NOT_FOUND` | `object` |
253+
| `VALIDATION_FAILED` | `fields` |
254+
| `FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE` | `object` |
255+
256+
⛔ What a Hono door never sends: `httpStatus`; `CONCURRENT_UPDATE`'s `currentRecord`,
257+
which the flat door sends; and an `object` the flat door takes from the request's route
258+
(on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the
259+
thrown one and can differ from the flat door's `error`: an unregistered object reads
260+
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there.
261+
206262
## HTTP Status Codes
207263
208264
ObjectStack uses standard HTTP status codes:
@@ -347,10 +403,11 @@ which grant would have worked is the disclosure the
347403
#### `VALIDATION_FAILED`
348404
**HTTP Status:** 400
349405
**Meaning:** Per-field record validation failed on a write
350-
**Envelope:** flat — built by the `/data` door's validation arm, so it is never nested
406+
**Envelope:** flat on the `@objectstack/rest` `/data` door, built by its validation arm;
407+
nested on a [Hono door](#hono-doors)
351408
352-
This is the code that carries **per-field** detail. The `fields` array is a **top-level
353-
sibling** of `code`, not a `details.fields` bag.
409+
This is the code that carries **per-field** detail. On the flat door the `fields` array is
410+
a **top-level sibling** of `code`; on a Hono door the same array is `error.details.fields`.
354411
355412
**Example** — `POST /api/v1/data/account` with a bad email and an out-of-range number:
356413
```json
@@ -538,8 +595,9 @@ than deriving it from this page.
538595
resolves to nothing, not a value of the wrong type. On a list read it also covers an
539596
unreserved query parameter, which `GET /data/:object` reads as a field filter.
540597
541-
**Envelope:** flat — built by the `/data` door's `INVALID_FIELD` arm, so it is never
542-
nested. `field` and `object` are **top-level siblings**, not a `details` bag.
598+
**Envelope:** flat on the `@objectstack/rest` `/data` door, built by its `INVALID_FIELD`
599+
arm, with `field` and `object` as **top-level siblings**. On a [Hono door](#hono-doors) it
600+
is nested, and the same two fields are `error.details.field` and `error.details.object`.
543601
544602
**Example:**
545603
```json
@@ -594,7 +652,8 @@ resource is named in `message`.
594652
}
595653
```
596654
597-
A *registered object* that does not exist is `OBJECT_NOT_FOUND`, also flat. Branch on all
655+
A *registered object* that does not exist is `OBJECT_NOT_FOUND`, also flat on this door; a
656+
[Hono door](#hono-doors) answers it nested, with `object` in `error.details`. Branch on all
598657
three if you handle both families.
599658
</Callout>
600659
@@ -633,8 +692,9 @@ never crosses HTTP. The refusal is emitted as the flat body shown above, and
633692
#### `DELETE_RESTRICTED`
634693
**HTTP Status:** 409
635694
**Meaning:** The record is still referenced by dependent records that cannot be cleared
636-
**Envelope:** flat — built by the `/data` door's `DELETE_RESTRICTED` arm, so it is never
637-
nested
695+
**Envelope:** flat on the `@objectstack/rest` `/data` door, built by its `DELETE_RESTRICTED`
696+
arm. On a [Hono door](#hono-doors) it is nested: `developerMessage`, `dependentObject` and
697+
`dependentCount` ride in `error.details`, and there is no `object`
638698
639699
**Example** — `DELETE /api/v1/data/account/acc_123`:
640700
```json
@@ -989,8 +1049,9 @@ function readError(body) {
9891049
message: nested ? err.message : (typeof body?.error === 'string' ? body.error : undefined),
9901050
// Marked by the producer as addressed to the end user; render verbatim.
9911051
userMessage: nested ? err.userMessage : body?.userMessage,
992-
// Per-field detail: `fields` is a top-level sibling on the flat door.
993-
fields: nested ? undefined : body?.fields,
1052+
// Per-field detail: a top-level sibling on the flat door, `error.details.fields`
1053+
// on a Hono door.
1054+
fields: nested ? err.details?.fields : body?.fields,
9941055
nested,
9951056
};
9961057
}
@@ -1039,8 +1100,9 @@ showToast(userMessage ?? generic[code] ?? message ?? 'An error occurred');
10391100
10401101
### ✅ Handle Field-Specific Validation Errors
10411102
1042-
Per-field detail comes from the flat `/data` door as `VALIDATION_FAILED`, with `fields` as
1043-
a **top-level sibling**. ⛔ There is no `details.fields` bag on either envelope.
1103+
Per-field detail comes as `VALIDATION_FAILED`: from the flat `/data` door with `fields` as
1104+
a **top-level sibling**, and from a [Hono door](#hono-doors) as `error.details.fields`.
1105+
`readError` above reads both.
10441106
10451107
**Good:**
10461108
```javascript

0 commit comments

Comments
 (0)