api: machine-readable code and args on every error body #224

Closed
opened 2026-10-12 14:21:24 +02:00 by dominik.polakovics · 0 comments

Summary

Every error the harness's HTTP API answers with gains a machine-readable code and its args, next to the English message it carries today. This is the server half of translating the editor: the SPA will translate errors by code (see the follow-up issue on the editor's i18n runtime), while the server itself never translates anything.

Current behavior

Errors reach the SPA as English prose only:

  • {"error": "<message>"} from writeJSONError, with the message built by fmt.Sprintf at the call site, in handlers (internal/server) or in the operation packages (pageops.OpError, and the equivalents in elemops, mediaops).
  • {"error": "<field>: <msg>", "errors": [{"field", "msg"}]} from writeFieldErrors, the 422 field-error contract, with each element.FieldError.Msg coming from a field type's rule ("must be a number", "must be one of: a, b", …) or from pageops.FieldErrors.
  • The publish endpoint's two human messages (staticFeedbackMessage, emptyPublishMessage in internal/server/publish.go) and a handful of built-in labels the server composes ("Home", "Section", "Enabled", "Disabled", "Yes", "No").

The SPA (web/src/api/client.ts, ApiError) shows these strings verbatim and never parses them. Roughly 170 message sites: ~87 operation errors, ~54 handler errors, ~25 field rules, plus the publish messages and labels.

Desired behavior

  • Every error body carries code (a stable, dotted identifier such as page.exists, field.number, root.delete) and args (a JSON object of the values the message interpolates: page names, language codes and labels, field names, option lists as arrays). The English error string is unchanged, byte for byte, for every existing message. Agents, curl and the Go tests see no difference beyond the two new fields.
  • Every entry in the 422 errors list carries code and args the same way, next to the unchanged field and msg.
  • The publish messages and the six built-in labels are emitted with a code too, in whatever shape their current responses have room for (a code field next to message; a code next to label).
  • All codes live in one registry in Go, each with its English template, so a message is built from the template plus args in one place and the string cannot drift from the code. A Go test exports the registry (code → English template, arg names) to a JSON file under web/src/ that the SPA's catalog parity test will read. The test fails when the file on disk is stale, as the agents-doc drift test does for scaffold/site/AGENTS.md.
  • A caller that constructs an error with a code the registry does not know fails at test time (a test walks the registry) rather than at runtime.

Decisions

  • Codes with args, the server never localizes — the SPA owns the one translation runtime and the one catalog; JavaScript cannot read the request's language anyway and the host passes it as a plain header the SPA receives through GET /api/site (that echo is the editor-runtime issue's job, not this one). Decided in the planning session on 2026-10-12.
  • The English error and msg strings stay exactly as they are — the CLI, agents reading the API and every existing test rely on them. Translation is additive.
  • All ~170 sites in one pass, including handler errors an editor can only reach through a bug — converting a site to code plus args is the whole cost, and a parity test needs a complete registry to be meaningful. No partial state.
  • Sidecar load errors (the SchemaLoadError lists GET /api/elements, /api/backendlayouts and /api/modules return, shown by SchemaErrorBanner) and Hugo's build output are developer-facing diagnostics and stay English without a code. Same for every CLI message.
  • The [Translate to <label>:] placeholder the harness writes into content files is a machine marker and must not change.

Key interfaces

  • errorResponse and fieldErrorsResponse in internal/server/server.go — gain code and args.
  • element.FieldError (internal/element/values.go) — gains code and args; its rules() implementations per field type (ftype_*.go) produce them.
  • pageops.OpError and pageops.FieldErrors (internal/pageops/errors.go) and the errInvalid/errConflict/… constructors; elemops and mediaops have the same pattern.
  • writeJSONError and writeFieldErrors (internal/server/server.go), writeOpError (internal/server/pageops.go) — the write boundaries.
  • A new registry package (name is the implementer's call) holding every code and its English template, with the export test next to it.
  • README: the error-body contract (the {"error": …} shape and the 422 field-error format are documented in the "How it works" and API sections) must document code and args.

Acceptance criteria

  • Every non-2xx JSON body from the harness carries code and args; every entry in a 422 errors list does too.
  • For every existing message, the error/msg string is unchanged (existing Go tests pass without edits to their expected strings).
  • One Go registry maps each code to its English template; no error site builds its message outside it.
  • A Go test writes the registry to a JSON file under web/src/ and fails when the committed file is stale.
  • A Go test fails when an error site uses a code the registry lacks.
  • README documents the code and args fields on both error shapes.

How to verify

  • go test ./... passes; make build produces the binary.
  • curl -s http://127.0.0.1:8090/api/... for a few refusals (rename the root page, create a page whose slug exists, save a number field with text) shows code and args next to the unchanged message.
  • Delete the exported JSON and run go test ./...: the export test fails, naming the file.

Assumptions to check

  • That elemops and mediaops build their errors the way pageops does — look before assuming the constructors line up.
  • Where exactly README documents the error body (grep for "error":); there may be more than one place.

Out of scope

  • Any translation, catalog or language resolution — the editor-runtime issue.
  • Changing wording, status codes or which errors exist.
  • CLI output, sidecar load diagnostics, Hugo output.

Blocked by

None - can start immediately

## Summary Every error the harness's HTTP API answers with gains a machine-readable `code` and its `args`, next to the English message it carries today. This is the server half of translating the editor: the SPA will translate errors by code (see the follow-up issue on the editor's i18n runtime), while the server itself never translates anything. ## Current behavior Errors reach the SPA as English prose only: - `{"error": "<message>"}` from `writeJSONError`, with the message built by `fmt.Sprintf` at the call site, in handlers (`internal/server`) or in the operation packages (`pageops.OpError`, and the equivalents in `elemops`, `mediaops`). - `{"error": "<field>: <msg>", "errors": [{"field", "msg"}]}` from `writeFieldErrors`, the 422 field-error contract, with each `element.FieldError.Msg` coming from a field type's rule (`"must be a number"`, `"must be one of: a, b"`, …) or from `pageops.FieldErrors`. - The publish endpoint's two human messages (`staticFeedbackMessage`, `emptyPublishMessage` in `internal/server/publish.go`) and a handful of built-in labels the server composes (`"Home"`, `"Section"`, `"Enabled"`, `"Disabled"`, `"Yes"`, `"No"`). The SPA (`web/src/api/client.ts`, `ApiError`) shows these strings verbatim and never parses them. Roughly 170 message sites: ~87 operation errors, ~54 handler errors, ~25 field rules, plus the publish messages and labels. ## Desired behavior - Every error body carries `code` (a stable, dotted identifier such as `page.exists`, `field.number`, `root.delete`) and `args` (a JSON object of the values the message interpolates: page names, language codes and labels, field names, option lists as arrays). The English `error` string is unchanged, byte for byte, for every existing message. Agents, curl and the Go tests see no difference beyond the two new fields. - Every entry in the 422 `errors` list carries `code` and `args` the same way, next to the unchanged `field` and `msg`. - The publish messages and the six built-in labels are emitted with a code too, in whatever shape their current responses have room for (a `code` field next to `message`; a `code` next to `label`). - All codes live in one registry in Go, each with its English template, so a message is built from the template plus args in one place and the string cannot drift from the code. A Go test exports the registry (code → English template, arg names) to a JSON file under `web/src/` that the SPA's catalog parity test will read. The test fails when the file on disk is stale, as the agents-doc drift test does for `scaffold/site/AGENTS.md`. - A caller that constructs an error with a code the registry does not know fails at test time (a test walks the registry) rather than at runtime. ## Decisions - Codes with args, the server never localizes — the SPA owns the one translation runtime and the one catalog; JavaScript cannot read the request's language anyway and the host passes it as a plain header the SPA receives through GET /api/site (that echo is the editor-runtime issue's job, not this one). Decided in the planning session on 2026-10-12. - The English `error` and `msg` strings stay exactly as they are — the CLI, agents reading the API and every existing test rely on them. Translation is additive. - All ~170 sites in one pass, including handler errors an editor can only reach through a bug — converting a site to code plus args is the whole cost, and a parity test needs a complete registry to be meaningful. No partial state. - Sidecar load errors (the `SchemaLoadError` lists GET /api/elements, /api/backendlayouts and /api/modules return, shown by `SchemaErrorBanner`) and Hugo's build output are developer-facing diagnostics and stay English without a code. Same for every CLI message. - The `[Translate to <label>:] ` placeholder the harness writes into content files is a machine marker and must not change. ## Key interfaces - `errorResponse` and `fieldErrorsResponse` in `internal/server/server.go` — gain `code` and `args`. - `element.FieldError` (`internal/element/values.go`) — gains code and args; its `rules()` implementations per field type (`ftype_*.go`) produce them. - `pageops.OpError` and `pageops.FieldErrors` (`internal/pageops/errors.go`) and the `errInvalid`/`errConflict`/… constructors; `elemops` and `mediaops` have the same pattern. - `writeJSONError` and `writeFieldErrors` (`internal/server/server.go`), `writeOpError` (`internal/server/pageops.go`) — the write boundaries. - A new registry package (name is the implementer's call) holding every code and its English template, with the export test next to it. - README: the error-body contract (the `{"error": …}` shape and the 422 field-error format are documented in the "How it works" and API sections) must document `code` and `args`. ## Acceptance criteria - [ ] Every non-2xx JSON body from the harness carries `code` and `args`; every entry in a 422 `errors` list does too. - [ ] For every existing message, the `error`/`msg` string is unchanged (existing Go tests pass without edits to their expected strings). - [ ] One Go registry maps each code to its English template; no error site builds its message outside it. - [ ] A Go test writes the registry to a JSON file under `web/src/` and fails when the committed file is stale. - [ ] A Go test fails when an error site uses a code the registry lacks. - [ ] README documents the `code` and `args` fields on both error shapes. ## How to verify - `go test ./...` passes; `make build` produces the binary. - `curl -s http://127.0.0.1:8090/api/...` for a few refusals (rename the root page, create a page whose slug exists, save a number field with text) shows `code` and `args` next to the unchanged message. - Delete the exported JSON and run `go test ./...`: the export test fails, naming the file. ## Assumptions to check - That `elemops` and `mediaops` build their errors the way `pageops` does — look before assuming the constructors line up. - Where exactly README documents the error body (grep for `"error":`); there may be more than one place. ## Out of scope - Any translation, catalog or language resolution — the editor-runtime issue. - Changing wording, status codes or which errors exist. - CLI output, sidecar load diagnostics, Hugo output. ## Blocked by None - can start immediately
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#224
No description provided.