Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .changeset/rest-structured-arms-before-passthrough.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
---
"@objectstack/rest": patch
---

fix(rest): the bulk / metadata / UI error door consults the bespoke structured arms BEFORE its declared-status passthrough (#14541)

**Response-body change on published bulk doors.** One refusal used to produce
two different bodies depending on which route caught it. `resolveErrorResponse`
— the door behind `handleRouteError` / `sendThrownError`, used by `createMany`,
`updateMany`, `deleteMany`, `batch`, `clone`, the import/export routes and the
metadata / UI families — took its own declared-status passthrough BEFORE
delegating to `mapDataError`, which the single-record `/data` routes call
directly. An engine envelope that DECLARES `status` therefore short-circuited,
and every bespoke structured arm behind the delegation was unreachable from
those routes.

The project had already ruled on this exact shape, for one code:

> [#3770] `OBJECT_NOT_FOUND` is deliberately excluded from this
> status-passthrough: `mapDataError` owns its canonical envelope, and
> short-circuiting here would ship a second wire code for the same condition
> depending on which route caught it.

That exclusion never grew past its first case. This change generalises the
ruling instead of adding a second exclusion: the structured arms are lifted
into one `structuredCodeAnswer` classification that BOTH doors ask first, and
the bulk door answers a match by delegating to `mapDataError`, so the two
bodies are the same by construction rather than by coincidence.

**What callers on the bulk doors see change** — measured door-to-door, in
process, against the real producer shapes:

| producer (declares) | before, bulk door | after, bulk door |
| :-- | :-- | :-- |
| engine `DELETE_RESTRICTED` (409) | `{error, code}` | `+ developerMessage, dependentObject, dependentCount, object` |
| `ConcurrentUpdateError` (409) | `{error, code}` | `+ currentVersion, currentRecord, object` |
| `DuplicateRecordError` (409) | `{error: the engine's sentence, code: "DUPLICATE_RECORD"}` | `{error: the curated sentence, code: "UNIQUE_VIOLATION", developerMessage, field, object}` |
| `FEEDS_DISABLED` / `FILES_DISABLED` (403) | `{error, code}` | `+ object` |
| `ATTACHMENT_PARENT_ACCESS` / `ATTACHMENT_DELETE_DENIED` / `RECORD_NOT_ACCESSIBLE` (403) | `{error, code}` | `+ object` |
| engine `INVALID_FIELD` (400) | `{error, code}` | `+ field, object` |

In every row the STATUS is unchanged for that producer, and no key is removed.

**Where the added keys come from, and how documented each is.** They are the
keys the single-record `/data` door has always shipped for the same refusal —
⛔ *not* keys declared on `ApiErrorSchema`, which declares exactly `code`,
`declaredCode`, `message`, `userMessage`, `category`, `httpStatus`, `details`
and `requestId` and none of these. Their published status, measured on
`origin/main`:

- `field`, `object` — documented, `content/docs/protocol/kernel/http-protocol.mdx`, the 409 "Constraint Violations" body.
- `developerMessage`, `dependentObject`, `dependentCount` — documented, `content/docs/protocol/objectql/types.mdx` ("Required foreign keys") and `content/docs/api/data-api.mdx`.
- `currentVersion` — documented, `content/docs/api/wire-format.mdx` §7, "Concurrent Update — 409 Conflict".
- `currentRecord` — **shipped but undocumented**: no `content/docs/**` page describes it (the only textual match is the unrelated `currentRecordCount` tenant quota). It has been on the single-record door's 409 since the arm existed; this change puts it on the bulk doors too, still undocumented.

**One `code` VALUE changes, and what it restores differs by driver.** On the
SQL drivers the bulk doors answered `409 UNIQUE_VIOLATION` with the curated
sentence and `field` until #14095: the raw driver error declares no `status`,
so it fell past this passthrough into `mapDataError`'s `isUniqueViolationError`
arm. #14095's `DuplicateRecordError` DOES declare one, so from then on those
doors answered the engine spelling `DUPLICATE_RECORD` while the single-record
door was restored explicitly by #14389. This puts `UNIQUE_VIOLATION` back on
the bulk doors. On **driver-memory** the wire code was **already**
`UNIQUE_VIOLATION` before #14095 and never moved — its raw refusal declares
`code = 'UNIQUE_VIOLATION'` and `status = 409` itself, so it took the
passthrough, which relays a registered code verbatim. What changes for that
driver is the SENTENCE: its raw message quotes the offending values as JSON and
the curated one does not.

**⚠️ A vocabulary fork this puts side by side, disclosed rather than implied
away.** The `DUPLICATE_RECORD` arm answers the wire spelling
`UNIQUE_VIOLATION`. A batch or import ROW does not go through this
classification at all — `metadata-protocol`'s `toRowApiError` puts a thrown
registered code on the row verbatim, and `import-runner`'s row report does the
same, deliberately per #14095 — so after this change a **whole-request** failure
on `POST /data/:object/batch` or `POST /data/:object/import` answers
`UNIQUE_VIOLATION` while a **row** failure on the same route answers
`DUPLICATE_RECORD`. Neither half is new and neither is a regression; what is new
is that both spellings now appear in one route's responses. This change does not
pick a winner — the ledger's "if it merely re-spells a standard member, that
registration is a recorded waiver" and ADR-0112's one-name-per-concept both bear
on it, and moving either spelling is a published-contract change. **#14723
carries the decision.**

**Which doors.** Every route whose catch calls `handleRouteError` /
`sendThrownError`, plus each environment-scoped twin: `POST
/api/v1/data/:object/createMany`, `/updateMany`, `/deleteMany`, `/batch`, `POST
/api/v1/batch`, `POST /api/v1/data/:object/:id/clone`, `GET
/api/v1/data/:object/export`, the `POST /api/v1/data/:object/import` and
`/api/v1/data/import/jobs/…` family, `GET /api/v1/discovery`, `GET
/api/v1/openapi.json`, the `/api/v1/meta/**` family and `GET
/api/v1/ui/view/:object/:type`. Two more families reach the same door through
`classifiedRefusalAnswer` rather than a route catch, and one of them changes:

- **`POST /api/v1/analytics/dataset/query`** (and its environment-scoped twin) spreads every classified key onto its own envelope, and `service-analytics` throws `INVALID_FIELD` with `status`, `field` and `object` at three sites — so **that route's error body gains `field` and `object`** exactly as the bulk doors do. Newly pinned at key level, because its own envelope tests assert `code` and a message shape only.
- **The record-share family** re-dresses only `code` / `declaredCode` / `userMessage` / `error` into the nested ADR-0112 D5 envelope, so its key set does not move; its `error` SENTENCE would change for a `DuplicateRecordError`.

The single-record `/data` CRUD routes and `GET /api/v1/data/:object` call
`mapDataError` directly and are the reference this converges on; none of their
bodies move. `packages/rest/src/package-routes.ts` defines its own local
`sendThrownError` and is unaffected.

**Three boundaries this does NOT cross:**

- **The passthrough's 5xx half is untouched.** A producer-declared 5xx still takes that arm, prose dropped unconditionally (#5437 / #5582 / #5907).
- **A sandboxed producer keeps the unwrap door's answer** on the bulk door: the arms ship `error.message`, which for a QuickJS body is the debug wrapper #11588 exists to keep off this wire, so the consult declines an error carrying `innerMessage`.
- **The shared classification is declared-code only.** Nothing in it reads message TEXT to decide which condition an error is, which is why the `PERMISSION_DENIED` arm stays where it is.

**⚠️ Two exceptions to "nothing else moves", stated because they are true and
were measured rather than reasoned:**

1. **A sandboxed producer declaring a 5xx with `OBJECT_NOT_FOUND` or `INVALID_FIELD` now keeps that 5xx.** Before, it fell past the sandbox unwrap door (declared ≥ 500) into the arm below it; the arms now carry an explicit sandbox-origin clause, so it answers the declared status with the prose withheld — #5582's rule. Measured on `origin/main` and on this branch, both doors, per code:

- `OBJECT_NOT_FOUND` + declared `503`: **both** doors answered `404 {"error":"Object 'nope' is not registered","code":"OBJECT_NOT_FOUND","object":"nope"}` and now answer `503 {"error":"Internal server error","code":"OBJECT_NOT_FOUND"}`. A status move on **both** doors, not one.
- `INVALID_FIELD` + declared `502`: the single-record door answered `400` with the QuickJS debug wrapper as `error` and now answers `502` with the prose withheld; the bulk door already answered `502` and does not move.

So "behaviour-identical" is not strictly true and is not claimed. No producer in this repo throws either code from a sandboxed body with a declared 5xx, so no wire in service moves — the shapes are synthesised, and they are pinned rather than described.
2. **A structured arm answering a 5xx no longer overrides a 4xx the producer declared, on either door.** Only `ERR_DATASOURCE_UNAVAILABLE`'s 503 answers a 5xx, and its producer declares no `status` at all, so nothing moves on the wire — but the single-record door used to answer `503` for a synthesised `{code, status: 400}` where the bulk door answered `400`, and the two now agree.

Both doors are pinned against each other per producer, every guard case asserts
BOTH doors as either a convergence or a *named* accepted divergence, and a
drift guard scans **both** halves of `classifyDataError` so an arm added below
the shared consult — the position that produced this card — cannot diverge
silently. That guard found one on its first run: `RECORD_NOT_FOUND` is a real
remaining instance, left open on purpose and recorded as a known gap citing
#14725 rather than quietly excused.
Loading
Loading