Skip to content

Commit baeb4f0

Browse files
os-helpclaude
andauthored
fix(spec): declare the external-federation error family's HTTP status (#7739) (#7791)
A write to a read-only federated external object was refused correctly — `ExternalWriteForbiddenError` names the datasource, its `schemaMode` and both flags that would be required, and nothing is applied — but reached the client as a bare 500 INTERNAL_ERROR with no `code`, indistinguishable from a crash. The `code` was never the missing half: `EXTERNAL_WRITE_FORBIDDEN` is already registered in the ADR-0112 error-code ledger. The missing half was an HTTP status, which no exit can invent — so the refusal fell past every structured branch of `mapDataError` and left through the terminal `UNCLASSIFIED_FAULT`, which sanitises to 500 and ships no code the producer never declared. Fixed at the producer, for the whole `EXTERNAL_ERROR_CODES` family at once: new `EXTERNAL_ERROR_HTTP_STATUS` maps every code to its status, and each error class carries it as `status`. Every HTTP exit in the repo already resolves `status` then `statusCode` (`declaredHttpStatus`, `resolveErrorResponse`, `HttpDispatcher.errorFromThrown`, `dispatcher-plugin.errorResponseBase`, `endpoint-executor`, `domains/actions`, `plugin-hono-server`), so one table fixes every door; a branch in `rest-server.ts` would have fixed one. `rest-server.ts` is untouched. EXTERNAL_WRITE_FORBIDDEN 403 policy refusal, not malformed input EXTERNAL_SCHEMA_MODE_VIOLATION 403 same, for DDL EXTERNAL_SCHEMA_MISMATCH 503 a deployment state, not the caller `satisfies Record<ExternalErrorCode, number>` makes a future gate that adds a code without a status a compile error. Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 591f675 commit baeb4f0

6 files changed

Lines changed: 533 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the external-federation error family declares its HTTP status, so a write refusal stops leaking as a bare 500 (#7739)
6+
7+
A write to a read-only federated external object was refused correctly on the
8+
server — `ExternalWriteForbiddenError` names the datasource, its `schemaMode`,
9+
and both flags that would be required, and nothing is applied (the gate throws
10+
before the driver is reached) — but it reached the client as a bare
11+
**500 INTERNAL_ERROR with no `code`**, indistinguishable from the server
12+
falling over.
13+
14+
The `code` existed the whole way down: `EXTERNAL_WRITE_FORBIDDEN` is registered
15+
in the ADR-0112 error-code ledger. What the error did not carry was an HTTP
16+
**status**, and no HTTP exit can invent one — so the refusal fell past every
17+
structured branch of the REST boundary, matched no message heuristic, and left
18+
through the terminal sanitised 500 that drops the `code` with it.
19+
20+
**The whole family now declares its status, in one table.** New export
21+
`EXTERNAL_ERROR_HTTP_STATUS` (`@objectstack/spec/shared`) maps every
22+
`EXTERNAL_ERROR_CODES` member to the status it is reported with, and each of
23+
the three error classes carries the corresponding value as `status`:
24+
25+
| code | status | why |
26+
| --- | --- | --- |
27+
| `EXTERNAL_WRITE_FORBIDDEN` | **403** | a policy refusal — the identical body succeeds once `datasource.external.allowWrites` and `object.external.writable` are both on, so 400/422 ("fix your request") would be a lie and 409 promises a retry that cannot help |
28+
| `EXTERNAL_SCHEMA_MODE_VIOLATION` | **403** | the same sentence about DDL: `schemaMode !== 'managed'` forbids it, and no rewritten request changes that |
29+
| `EXTERNAL_SCHEMA_MISMATCH` | **503** | not the caller at all — the deployment's metadata and the remote table diverged, only an operator can reconcile them, and it may clear (the reading `ERR_DATASOURCE_UNAVAILABLE` already gets) |
30+
31+
Declared at the **producer** rather than in a REST error map, because every
32+
HTTP exit in the framework already resolves `status` then `statusCode` —
33+
`mapDataError`'s `declaredHttpStatus`, `resolveErrorResponse`,
34+
`HttpDispatcher.errorFromThrown`, `dispatcher-plugin.errorResponseBase`,
35+
`endpoint-executor`, `domains/actions`, `plugin-hono-server`. One table fixes
36+
every door at once; a branch in the REST server would have fixed one and left
37+
the runtime dispatcher, the endpoint executor and the CLI answering 500 for the
38+
same throw. `satisfies Record<ExternalErrorCode, number>` makes a future gate
39+
that adds a code without a status a compile error, so the family cannot
40+
silently re-acquire a member that leaks as a 500.
41+
42+
**What callers see now.** `POST /api/v1/data/<external_object>` against a
43+
read-only federated object answers `403` with `code:
44+
"EXTERNAL_WRITE_FORBIDDEN"` and the refusal detail naming both flags to set,
45+
instead of `500` with no `code`. Behaviour is unchanged — nothing was applied
46+
before and nothing is applied now; only the envelope was wrong.

0 commit comments

Comments
 (0)