feat(api): machine-readable code and args on every error body #228

Merged
dominik.polakovics merged 5 commits from afk/224 into main 2026-10-12 15:30:21 +02:00

Closes #224

Every error body the harness API sends now includes a machine-readable code and its args next to the English text. The English text is unchanged, byte for byte. The server still translates nothing; the editor will translate by code (that is the follow-up issue).

What changed

  • Registry: internal/apimsg. Every code is defined once, in internal/apimsg/codes_*.go (288 codes), as var X = define("dotted.code", "English template").
    • Templates use {name}, {name|q} (printed like Go's %q) and {name|list} (joined with , ).
    • A message is built only as apimsg.New(apimsg.X, apimsg.Args{...}), and its English text is always rendered from the template.
  • Tests that guard the registry:
    • TestCallSites walks every non-test Go file in the module and fails on:
      • a code that is not a registry var;
      • a Code("…") conversion or a Message{} literal;
      • args that are not a literal whose keys match the template's placeholders exactly;
      • a registered code that nothing uses.
    • TestRegistry checks that the parsed definitions are exactly the registry.
    • TestExport compares web/src/i18n/servermessages.json (code → {template, args}) with the registry. It fails, naming the file, when the file is stale or missing. Regenerate it with go test ./internal/apimsg -run TestExport -update.
  • Plain error bodies are {error, code, args}. args is always an object, {} when there is nothing to interpolate.
  • 422 field errors:
    • The top-level summary is fields.summary {field, msg}.
    • Every errors[] entry carries its own code and args. element.FieldError gains Code/Args and is built with NewFieldError.
  • Operation errors: pageops, elemops and mediaops errors implement apimsg.Coded. writeError / apimsg.FromError keep a coded error's code. Any other error goes out as error.unexpected {detail}.
  • English fragments are never passed as args. Messages that were assembled from fragments are split into one code per whole sentence. Examples: the reserved-slot verbs, the content root vs. a section, a new vs. a moved page, the language-restricted operations, and l10n's "cannot be translated before …".
  • Errors from outside our code (filesystem, git, Hugo, decoders, the content parser) appear as an args.detail under a wrapper code that describes the step that failed.
  • Server-composed messages and labels:
    • The publish responses carry a code beside message. A publish job's state carries messageCode for its message and code/args for its error.
    • The publish, media-referenced and already-running 409 error bodies carry code/args.
    • /api/git/state gains reasonCode/reasonArgs; the badges and pending list gain unavailableCode/unavailableArgs.
    • Built-in labels gain labelCode: the Enabled field, and the module filters' Enabled/Disabled/Yes/No options.
    • The root's built-in "Home" title in the page tree and the pending list gains titleCode.
  • README documents the contract under "Coded messages", next to the 422 field-error format.

Deliberately unchanged

  • Sidecar load errors, hugoOutput and all CLI output stay plain English with no code, as the issue decided.
  • The plain-text http.Error 405 on /api/status is left as is, because it is not a JSON body.
  • scaffold/site/AGENTS.md and the scaffolded skills don't need changes: they document sidecars and harness commands, not the HTTP error body, and no command, flag or scaffold file changed.

Known compromises

  • (allowed: {allowed}) in the page-type refusals. The arg is a JSON array, but the English text renders it the way Go's %v does ([post section]) so the text stays byte-identical.
  • publish.mediaMissing {refs}. The arg is the pre-rendered list text, which still contains English fragments such as "(referenced by …)" and "and N more". It can't be split byte-identically with the current template grammar.
  • body.inlineParam. It carries the inner field error's English text as detail.

Verification

  • go test ./..., go vet ./... and gofmt are clean; make build works; npm test in web/ passes (2800 tests).
  • No expected English string in any existing Go test changed. Test edits are shape-only: they adapt to the new fields and signatures.
  • New HTTP tests (internal/server/codes_test.go, codes_publish_test.go) cover:
    • root rename → page.rootRename
    • a create whose slug exists → page.exists {slug}
    • a number field saved with text → fields.summary with errors[0] = field.number
    • request.method and request.missingParam
    • a table of about 30 error requests, each of whose bodies has a non-empty code and an args object
    • the publish, media and git bodies
  • Curl against harness serve on a copy of testdata/site:
    • {"error":"the content root page cannot be renamed","code":"page.rootRename","args":{}}
    • {"error":"a page named \"posts\" already exists here","code":"page.exists","args":{"slug":"posts"}}
    • {"error":"unknown language: \"xx\"","code":"editor.unknownLang","args":{"lang":"xx"}}
  • Deleting web/src/i18n/servermessages.json makes go test ./internal/apimsg fail with web/src/i18n/servermessages.json is missing …; run go test ./internal/apimsg -run TestExport -update.

🤖 Generated with Claude Code

Closes #224 Every error body the harness API sends now includes a machine-readable `code` and its `args` next to the English text. The English text is unchanged, byte for byte. The server still translates nothing; the editor will translate by code (that is the follow-up issue). ## What changed - **Registry: `internal/apimsg`.** Every code is defined once, in `internal/apimsg/codes_*.go` (288 codes), as `var X = define("dotted.code", "English template")`. - Templates use `{name}`, `{name|q}` (printed like Go's `%q`) and `{name|list}` (joined with `, `). - A message is built only as `apimsg.New(apimsg.X, apimsg.Args{...})`, and its English text is always rendered from the template. - **Tests that guard the registry:** - `TestCallSites` walks every non-test Go file in the module and fails on: - a code that is not a registry var; - a `Code("…")` conversion or a `Message{}` literal; - args that are not a literal whose keys match the template's placeholders exactly; - a registered code that nothing uses. - `TestRegistry` checks that the parsed definitions are exactly the registry. - `TestExport` compares `web/src/i18n/servermessages.json` (code → `{template, args}`) with the registry. It fails, naming the file, when the file is stale or missing. Regenerate it with `go test ./internal/apimsg -run TestExport -update`. - **Plain error bodies** are `{error, code, args}`. `args` is always an object, `{}` when there is nothing to interpolate. - **422 field errors:** - The top-level summary is `fields.summary {field, msg}`. - Every `errors[]` entry carries its own code and args. `element.FieldError` gains `Code`/`Args` and is built with `NewFieldError`. - **Operation errors:** `pageops`, `elemops` and `mediaops` errors implement `apimsg.Coded`. `writeError` / `apimsg.FromError` keep a coded error's code. Any other error goes out as `error.unexpected {detail}`. - **English fragments are never passed as args.** Messages that were assembled from fragments are split into one code per whole sentence. Examples: the reserved-slot verbs, the content root vs. a section, a new vs. a moved page, the language-restricted operations, and l10n's "cannot be translated before …". - **Errors from outside our code** (filesystem, git, Hugo, decoders, the content parser) appear as an `args.detail` under a wrapper code that describes the step that failed. - **Server-composed messages and labels:** - The publish responses carry a `code` beside `message`. A publish job's state carries `messageCode` for its message and `code`/`args` for its error. - The publish, media-referenced and already-running 409 error bodies carry `code`/`args`. - `/api/git/state` gains `reasonCode`/`reasonArgs`; the badges and pending list gain `unavailableCode`/`unavailableArgs`. - Built-in labels gain `labelCode`: the Enabled field, and the module filters' Enabled/Disabled/Yes/No options. - The root's built-in "Home" title in the page tree and the pending list gains `titleCode`. - **README** documents the contract under "Coded messages", next to the 422 field-error format. ## Deliberately unchanged - Sidecar load errors, `hugoOutput` and all CLI output stay plain English with no code, as the issue decided. - The plain-text `http.Error` 405 on `/api/status` is left as is, because it is not a JSON body. - `scaffold/site/AGENTS.md` and the scaffolded skills don't need changes: they document sidecars and harness commands, not the HTTP error body, and no command, flag or scaffold file changed. ## Known compromises - **`(allowed: {allowed})` in the page-type refusals.** The arg is a JSON array, but the English text renders it the way Go's `%v` does (`[post section]`) so the text stays byte-identical. - **`publish.mediaMissing {refs}`.** The arg is the pre-rendered list text, which still contains English fragments such as "(referenced by …)" and "and N more". It can't be split byte-identically with the current template grammar. - **`body.inlineParam`.** It carries the inner field error's English text as `detail`. ## Verification - `go test ./...`, `go vet ./...` and gofmt are clean; `make build` works; `npm test` in `web/` passes (2800 tests). - No expected English string in any existing Go test changed. Test edits are shape-only: they adapt to the new fields and signatures. - New HTTP tests (`internal/server/codes_test.go`, `codes_publish_test.go`) cover: - root rename → `page.rootRename` - a create whose slug exists → `page.exists {slug}` - a number field saved with text → `fields.summary` with `errors[0]` = `field.number` - `request.method` and `request.missingParam` - a table of about 30 error requests, each of whose bodies has a non-empty `code` and an `args` object - the publish, media and git bodies - Curl against `harness serve` on a copy of `testdata/site`: - `{"error":"the content root page cannot be renamed","code":"page.rootRename","args":{}}` - `{"error":"a page named \"posts\" already exists here","code":"page.exists","args":{"slug":"posts"}}` - `{"error":"unknown language: \"xx\"","code":"editor.unknownLang","args":{"lang":"xx"}}` - Deleting `web/src/i18n/servermessages.json` makes `go test ./internal/apimsg` fail with `web/src/i18n/servermessages.json is missing …; run go test ./internal/apimsg -run TestExport -update`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Codes, English templates, rendering, the call-site walk and the
export to web/src for the SPA. FieldError gains code and args.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Field rules, site checks and form checks produce coded messages; the
sidecar default wording stays a plain load error. The Enabled field,
the module filter options and the built-in Home title carry a label
code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OpError carries a coded message; refusals built from English fragments
are split into one code per sentence. elemops' refusal helpers and body
field errors return coded messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(server): answer every error body with a code and args
All checks were successful
ci / build-and-test (pull_request) Successful in 15m56s
ci / release (pull_request) Has been skipped
1322a0c371
Handlers write coded messages: plain errors as {error, code, args}, the
422 summary as fields.summary, and the publish, media and git bodies
with code/args beside their errors and codes beside their messages and
labels. pagetree's unknown language and l10n's translate refusals are
coded at their origin. The registry is exported to
web/src/i18n/servermessages.json and the README documents the contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

[autoland] verdict: pass
Sign in to join this conversation.
No reviewers
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!228
No description provided.