Skip to content

fix(service-storage,service-i18n): emit the declared error envelope, not a bare { error } (#3675) - #3687

Merged
os-zhuang merged 2 commits into
mainfrom
claude/route-audit-tranche-3-service-wsxqaf
Jul 27, 2026
Merged

fix(service-storage,service-i18n): emit the declared error envelope, not a bare { error } (#3675)#3687
os-zhuang merged 2 commits into
mainfrom
claude/route-audit-tranche-3-service-wsxqaf

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes #3675. Paired with objectstack-ai/objectui#2869that one is load-bearing, see “What nearly broke”.

The drift

#3636 aligned the success bodies of the autonomously-mounted service routes, because those were the ones breaking ObjectStackClient.unwrapResponse. The error bodies stayed a bare { error: '<message>' } — with the code, where one existed at all, as a sibling of error rather than a field of it — against a contract (BaseResponseSchema + ApiErrorSchema) declaring { success: false, error: { code, message } }.

So the same SDK method returned two different error shapes depending on which provider mounted the route: a caller reading body.error.message got the real message from the dispatcher and undefined from these services.

All 32 sites (27 in storage-routes.ts, 5 in i18n-service-plugin.ts) now go through one sendError helper per module — the nested-error shape the sibling services already use (settings-routes.ts, share-link-routes.ts), plus the success flag those two still omit and the contract requires.

Which target, and why not the dispatcher

The issue proposed aligning to the dispatcher. Checking first turned up four live error dialects, and the declared contract matches none of them:

Producer Shape
contract.zod.ts (declared) { success, error: { code: string, message, … } }
http-dispatcher.ts { success: false, error: { message, code: <HTTP number>, details } }
rest-server.ts { error: <string>, code: <SEMANTIC_STRING> }
settings-routes.ts, share-link-routes.ts { error: { code, message } } — no success
service-i18n, service-storage { error: <string> }, sometimes + code at the top level

So I aligned to the contract, not the dispatcher. The dispatcher puts the HTTP status in error.code where a semantic string is declared — and then parks the real code in details (this.error(msg, 403, { code: 'PERMISSION_DENIED' })) to work around its own occupied field, which is the tell that the number is in the wrong place. Both halves of the target shape have in-repo precedent, so nothing here is invented.

That deviation is now pinned to exactly one field by a test in http-dispatcher.test.ts rather than described in prose. toEqual(['code']) is deliberately exact: if a second field starts deviating it fails, and if the dispatcher is ever fixed it also fails and the pin should be deleted. Not fixed here — error.code is read by the SDK, the console and the dogfood suite.

Note the rest-server.ts row is documented, not accidental: wire-format.mdx §7 and error-catalog.mdx both describe the flat envelope as what /api/v1/data/* emits. This PR does not touch it.

What nearly broke

AUTH_REQUIRED, ATTACHMENT_DOWNLOAD_DENIED and FILE_DOWNLOAD_DENIED moved from body.code to body.error.code. #3675 flagged “check @object-ui error-surfacing paths first” as a precaution; it turned out to be a real hit.

  • SDK — unaffected. It already reads errorBody?.code || errorBody?.error?.code, one of four shapes its error path sniffs for. That shim is the consumer-side symptom Prime Directive Add comprehensive test suite for Zod schema validation #12 says to cure at the producer.
  • Console upload path — unaffected. It stringifies the whole body into the message, so its substring match survives either shape. Verified rather than assumed.
  • Console download path — would have broken. RecordAttachmentsPanel read (await res.json())?.code structurally, so every gated download would have degraded from “You don't have access to download this attachment.” to the generic “Download failed (403)”. Fixed in objectui#2869, reading both dialects — a console build ships independently of the server it talks to, so tolerance is correct here rather than a synchronized cutover, and it makes the merge order of the two PRs irrelevant.
  • cloud repo — checked, no consumers. It mounts these services but never reads their error bodies.

Also re-pointed: 5 assertions in storage-routes.test.ts and 3 in the attachments dogfood matrix. The other 4 dogfood assertions in that file (FILES_DISABLED, ATTACHMENT_PARENT_ACCESS, PERMISSION_DENIED) are produced by rest-server.ts, which this PR does not touch, so they deliberately still read the top-level code.

Guards

A new error-envelope.conformance.test.ts in each service, guarding both directions:

  1. Runtime — every distinct error branch is driven through the real registrar (11 cases in storage, 6 in i18n, one per distinct code) and the body parsed against the real BaseResponseSchema imported from packages/spec, not a local restatement that could drift from the schema it claims to check.
  2. Source scan — the module is scanned so a new route cannot quietly reintroduce the bare shape, and so the envelope is built in exactly one place. Without this, the suite would only ever cover the branches that existed the day it was written.

Both halves mutation-checked: reintroducing one bare error site fails the scan; dropping the console's nested read fails the objectui cases.

One case is there for a reason worth naming: ApiErrorSchema requires message, so a thrown non-Error would otherwise emit an invalid body. throw 'a bare string' is covered.

Scope held

  • service-storage's success bodies are still three shapes ({ data }, bare { url }, { ok, key }, none carrying success: true). Unlike Route audit tranche 3: autonomously-mounted service routes (service-storage / service-i18n) — #3587 follow-up #3636's success fix this one is not additive — normalizing bare { url } moves data and breaks getDownloadUrl's reader — so it gets its own issue instead of a quiet ride along with this one.
  • Code vocabulary not resolved. error-catalog.mdx documents StandardErrorCode as lowercase snake_case (validation_error); every SCREAMING_SNAKE code here is either pre-existing (AUTH_REQUIRED, ATTACHMENT_DOWNLOAD_DENIED, FILE_DOWNLOAD_DENIED — renaming them would break the console and the dogfood suite) or matches its immediate neighbours (settings-routes.ts emits SETTINGS_FORBIDDEN, INTERNAL). This PR moves codes into the declared envelope; reconciling the two vocabularies is a separate spec decision.
  • rest-server.ts and the two sibling services keep their dialects.

Not an oversight in the ledgers

The route ledgers (#3563#3656) could never have caught this: they audit which routes exist and whether the SDK can address them, not what comes back. That boundary is now recorded as §12 of the audit doc — “the route exists” and “the route answers in the declared shape” are two questions, and the second needs the contract imported into the assertion.

wire-format.mdx §7 gains a short subsection: its flat envelope is the data-route form, and the service-mounted routes now answer in the declared one.

Tests

Suite Result
@objectstack/service-storage 196 passed (17 files)
@objectstack/service-i18n 60 passed (5 files)
@objectstack/runtime 655 passed (46 files)
@objectstack/client 175 passed (13 files)

Dogfood assertions are re-pointed but not run locally (they need a live stack); they will run in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K35y3ovfWtCkBHYFCqUfAt

…not a bare `{ error }` (#3675)

#3636 aligned the SUCCESS bodies of the autonomously-mounted service routes,
because those were the ones breaking `ObjectStackClient.unwrapResponse`. The
error bodies were left alone and stayed a bare `{ error: '<message>' }` — with
the code, where one existed at all, as a SIBLING of `error` rather than a field
of it — against a contract (`BaseResponseSchema` + `ApiErrorSchema`) declaring
`{ success: false, error: { code, message } }`. So the same SDK method returned
two different error shapes depending on which provider mounted the route.

All 32 sites (27 in `storage-routes.ts`, 5 in `i18n-service-plugin.ts`) now go
through one `sendError` helper per module: the nested-`error` shape the sibling
services already use (`settings-routes.ts`, `share-link-routes.ts`), plus the
`success` flag those two still omit and the contract requires.

The codes moved, and that is the breaking part. `AUTH_REQUIRED`,
`ATTACHMENT_DOWNLOAD_DENIED` and `FILE_DOWNLOAD_DENIED` were at `body.code` and
are now at `body.error.code`. The SDK is unaffected — it already reads
`errorBody?.code || errorBody?.error?.code`, one of four shapes its error path
sniffs for, which is the consumer-side shim Prime Directive #12 says to cure at
the producer. The console's attachment panel was NOT: it read the top level
only, so every gated download would have degraded to "Download failed (403)".
Fixed in objectui to read both dialects, since a console build ships
independently of the server it talks to.

Guarded both ways. A new `error-envelope.conformance.test.ts` in each service
drives every distinct error branch through the real registrar and parses the
body against the real `BaseResponseSchema` imported from `packages/spec` — not
a local restatement that could drift from the schema it claims to check — and
scans the module source so a new route cannot quietly reintroduce the bare
shape. Without the second half the suite would only ever cover the branches
that existed the day it was written. Both halves are mutation-checked.

The route ledgers (#3563#3656) could not have caught this: they audit which
routes exist and whether the SDK can address them, not what comes back.
Recorded as §12 of the audit doc, with the four error dialects found live.

Measured and deliberately left alone: the dispatcher does not conform either —
it puts the HTTP status in `error.code` where the contract declares a semantic
string, and parks the real code in `details` to work around its own occupied
field. Now pinned to exactly one deviating field by a test rather than prose.
Also unchanged: service-storage's success bodies are still three shapes of
their own, none carrying `success: true` — non-additive, so it gets its own
issue instead of a quiet ride along with this one.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 2:32pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): packages/qa, @objectstack/runtime, packages/services.

24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/qa, packages/runtime)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime, packages/services)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…lared envelope (#3675)

`wire-format.mdx` §7 describes the flat `{ error, code }` envelope, which is
what the kernel REST server emits for `/api/v1/data/*`. With the storage and
i18n routes moved to the declared `BaseResponse` shape, a reader following §7
would look for `body.code` on those routes and find nothing.

Says which envelope belongs to which surface, and that SDK callers need not
branch because `ObjectStackClient` normalizes both.

Co-Authored-By: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 14:43
@os-zhuang
os-zhuang merged commit bd68f08 into main Jul 27, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/route-audit-tranche-3-service-wsxqaf branch July 27, 2026 14:43
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…ing; the HTTP status moves to `httpStatus` (#3842) (#3971)

`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
into the field `ApiErrorSchema` reserves for a semantic string, so `error.code`
came back as 400/403/503 — a number duplicating the response status and
occupying the one slot a caller branches on. The real code then had to go
elsewhere, and did, three elsewheres: `details.code`, `details.type` and
`error.type`.

`code` is now the semantic string, `httpStatus` carries the number, and
`details` is genuine context only. Every code already on the wire moves
verbatim — PERMISSION_DENIED, ROUTE_NOT_FOUND, PASSWORD_EXPIRED,
PROJECT_MEMBERSHIP_REQUIRED, VALIDATION_FAILED, unauthenticated. This moves a
field; #3841 still owns renaming any of them. A branch with no code of its own
gets one derived from the status via one declared map in the spec
(`HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`), drawing only on
catalogued `StandardErrorCode` members.

Spec: `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode` gains
`method_not_allowed` / `precondition_required` (both additive). BREAKING —
`DispatcherErrorCode` members go from '404'|'405'|'501'|'503' to the four
semantic spellings the removed `error.type` declared, and
`DispatcherErrorResponseSchema.error.code` becomes a string; that schema had
declared the opposite of `ApiErrorSchema` for the same field, which is what let
the deviation stand.

Also aligned, being the same wire surface: `dispatcher-plugin`'s
`errorResponseBase` (which used to discard a thrown error's `.code` outright,
having nowhere to put it) and its inline 404, plus the MCP 405. All bodies now
come from one builder, guarded both ways by `error-envelope.conformance.test.ts`
— every branch driven and parsed against the schema imported from the spec, plus
a source scan so a new branch cannot reintroduce a numeric `code`.

Deletes the #3687 pin, which asked to be deleted rather than updated once the
dispatcher was fixed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-i18n error responses are bare { error }, not the declared { success: false, error: {...} } envelope

2 participants