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
53 changes: 53 additions & 0 deletions .changeset/demoted-code-withheld-on-undeclared-5xx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/types": patch
---

fix(types): a DEMOTED `declaredCode` is withheld on a 5xx the producer did not declare (#12509, ADR-0112)

**Wire change, for undeclared server faults only.** When a 5xx has its prose
withheld, the producer's demoted `declaredCode` is now withheld with it —
but only when the fallback-to-500 picked that code up from a producer that
declared no HTTP answer. An author-declared code is untouched at every status.

FROM (`origin/main`, measured through the real routes):

```
POST /api/v1/packages/publish → 500 {"error":{"code":"INTERNAL_ERROR",
"message":"Internal server error","declaredCode":"SQLITE_ERROR"}}
POST /api/v1/analytics/query → 500 {"error":{"code":"INTERNAL_ERROR",
"message":"Internal server error","httpStatus":500,"declaredCode":"42P01"}}
```

TO:

```
POST /api/v1/packages/publish → 500 {"error":{"code":"INTERNAL_ERROR",
"message":"Internal server error"}}
POST /api/v1/analytics/query → 500 {"error":{"code":"INTERNAL_ERROR",
"message":"Internal server error","httpStatus":500}}
```

UNCHANGED — the author-authored channel the ADR-0112 amendment wrote
`declaredCode` for:

```
{ status: 503, code: 'ACME_LEDGER_OFFLINE' }
→ 503 {"error":{"code":"SERVICE_UNAVAILABLE",…,"declaredCode":"ACME_LEDGER_OFFLINE"}}
```

`SQLITE_ERROR` vs `42P01` names the backend, which is one of the two
disclosures the 5xx message withhold exists to prevent (the other,
identifiers, was already covered). Maintainer ruling 2026-08-27, option D.

**What a consumer must know.** A `declaredCode` on a 5xx now means the
producer declared that fault itself, which is a stronger guarantee than the
field carried before; nothing that was a *registered* code moves, and no 4xx
moves. A producer that spells a code but declares no status loses that code
on a 5xx — declare the status the refusal means and the spelling is kept.

The distinction lives in ONE place, `serverFaultProvenance`
(`packages/types/src/thrown-http-error.ts`), read by `demotedDeclaredCode` —
the read every door already makes — so all five emitting exits inherit it and
no registrar carries a variant. The prose axis of the same ruling (the
dispatcher door adopting the structural withhold for every declared 5xx
message) is #12281 and is deliberately not applied here.
10 changes: 10 additions & 0 deletions docs/adr/0112-error-code-vocabulary-and-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,16 @@ Nine rulings, D1–D9.
>
> **Where it is pinned.** `packages/rest/src/rest-thrown-code-vocabulary.test.ts`, in both directions — an unregistered code demotes, and a registered code still arrives verbatim in `code`. The second half is the one a regression does not redden, which is why it is written down.

> **Amendment (2026-08-27, [#12509](https://github.com/objectstack-ai/objectstack/issues/12509)) — the channel has a 5xx SCOPE: a demoted code is withheld with the prose when the producer declared no server fault; an author-declared code survives.** The two amendments above say WHICH spelling goes on the wire; neither says whether the open channel is in scope for 5xx sanitisation, and at none of the doors was the answer "no" a decision anyone made — it was what each door's control flow produced. Measured on `origin/main` before this ruling: a bare driver error (`{ code: 'SQLITE_ERROR' }` or `{ code: '42P01' }`, **no declared status**) resolves to `500 INTERNAL_ERROR`, the door withholds the leaky message ([#8086](https://github.com/objectstack-ai/objectstack/issues/8086)) — and then puts the driver's own dialect on the wire in `declaredCode`. `SQLITE_ERROR` vs `42P01` names the backend, which is one of the two disclosures the message withhold exists to prevent (the other, identifiers, was already covered).
>
> **Ruled (maintainer, 2026-08-27).** In 5xx sanitisation a **demoted** code — one the fallback-to-500 picked up from an **undeclared** producer — is withheld along with the prose; an **author-declared** `declaredCode` survives. Implemented **once at the shared resolver layer so all doors inherit one rule**; ⛔ no per-registrar variants. The rejected alternatives, all three named: recording the leak as a decision (it rests on an untested premise that can rot), withholding the author channel too (that drops the channel for exactly the metadata-app 5xx refusals this ADR's amendment wrote it for), and a per-door rule (it re-creates the divergence the two amendments above just closed).
>
> **The discriminator is the STATUS channel, and it could not be anything else.** A driver errno and an app's own spelling both arrive on `.code` as a plain string, so telling them apart by looking at the string would be a heuristic over an open channel — the consumer-side tolerance this ADR exists to forbid, and unfalsifiable besides (nothing stops an app from spelling `SQLITE_ERROR`). "The producer declared an HTTP answer" is the one structural signal, and it is already computed: `ThrownHttpError.declaredStatus`. The cost is stated rather than hidden — **a producer that spells a code but declares no status loses that code on a 5xx**, and keeps it by declaring the status it means, which is the shape this ADR already asks for. The tenant-authored limb is untouched: `SandboxError` answers **400**, so the `DUPLICATE` witness rides a 4xx and is outside this scope entirely.
>
> **Where it is pinned.** `serverFaultProvenance` (`packages/types/src/thrown-http-error.ts`, anchored in `scripts/adr-anchors/`) is the ONE definition, read by `demotedDeclaredCode` — the read every door already makes, which is what makes "all doors inherit" a construction rather than five suites agreeing about literals. Pinned at the doors in `packages/types/src/thrown-http-error-5xx-code-withhold.test.ts` (the rule), `packages/rest/src/package-door-5xx-demoted-code-withhold.test.ts` and `packages/runtime/src/dispatcher-5xx-demoted-code-withhold.test.ts` (each door's wire, driven through its real route).
>
> **Scope, exactly.** This rules the CODE channel only. The PROSE axis of the same ruling — the dispatcher door adopting the structural withhold for every declared 5xx message — is [#12281](https://github.com/objectstack-ai/objectstack/issues/12281), a separate card with its own measurement-first step; it is the `'declared'` limb of the same `serverFaultProvenance` function and deliberately not applied here.

**D5 — One location, eventually: `error.code` carries the semantic code.** Target end-state, recorded here so the follow-ups have a fixed destination: the HTTP status lives on the transport and (optionally) `error.httpStatus`; `error.code` is always the semantic string; `error.details.code` and `error.type` are retired as code carriers. The dispatcher-occupation fix (#3689 sibling) and `ROUTE_NOT_FOUND`-in-`type` retirement land as follow-ups (Rollout, batch 3). The client's three-location probe is deleted only after both.

> **Amendment (2026-07-30, [#4007](https://github.com/objectstack-ai/objectstack/issues/4007)) — ruled and done.** With batch 3 landed, the client's parking-spot read (`error.details.code`) is deleted: SDK and server ship as a changesets fixed group, so the "newer SDK, older server" pairing it served is not a supported deployment — and batches 1–2 renamed the code *values* anyway, so a code dug out of an old server's parking spot would match no branch written against the current catalog; location-compat without value-compat protects nothing. The client's two remaining reads are the two *live* envelopes' declared spots (flat top-level `code`, wrapped `error.code`) — a present-tense fact, not a fallback chain; retiring the flat shape itself belongs to the envelope-convergence line (#3843 family), not this ADR. The D9b nesting fix (`category`/`retryable`, [#4006](https://github.com/objectstack-ai/objectstack/issues/4006)) landed in the same change.
Expand Down
Loading
Loading