docs(quick-261019-hgw): Translate hook ValidationError phrase keys in the request locale

This commit is contained in:
2026-10-19 12:54:34 +02:00
parent ce8d7efb67
commit 10992ea1a5
4 changed files with 385 additions and 5 deletions

View File

@@ -5,10 +5,10 @@ current_phase: "19.4"
current_phase_name: OIDC provider on wristband
status: planning
stopped_at: Roadmap reordered 2026-10-18; ready to plan Phase 19.4
last_updated: "2026-10-19T10:33:06.360Z"
last_updated: "2026-10-19T10:54:33.888Z"
last_activity: 2026-10-19
last_activity_desc: Phase 17 UAT complete (40/40) and re-verified passed; UAT redeployed with the menu and FAQ cache purge
state_head: 1fca32042b4cfae7b770d2b880ad752092d8643a
last_activity_desc: "Completed quick task 261019-hgw: Translate hook ValidationError phrase keys in the request locale"
state_head: ce8d7efb675a15840b5ee274d11625d920827147
progress:
total_phases: 42
completed_phases: 34
@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
Phase: 19.4 — OIDC provider on wristband
Plan: Not started
Status: Ready to plan
Last activity: 2026-10-19 - Phase 17 UAT complete (40/40) and re-verified passed; UAT redeployed with the menu and FAQ cache purge
Last activity: 2026-10-19 - Completed quick task 261019-hgw: Translate hook ValidationError phrase keys in the request locale
Progress: [████████░░] 83%
@@ -948,6 +948,7 @@ Recent decisions affecting current work:
| 40 | Goldens secret scan in check-phase19.1.sh (closes T-19.1-12) | 2026-10-18 | 8643195 | — |
| 261018-qbs | Fix 19.1 review WR-04 (chatbots dispatch checks IsAvailable) and WR-05 (chatvideo user.Deleting heir + LiveKit cleanup); extending.md optional-dependency go.mod note | 2026-10-18 | 97daec5 | [261018-qbs-fix-19-1-review-findings-wr-04-chatbots-](./quick/261018-qbs-fix-19-1-review-findings-wr-04-chatbots-/) |
| 261019-05g | Phase 17 verify-work unblock (COVERAGE.md reason, goal as user story, sm-sitemap-plugin requires v0.2.0) and console docs example of the application binary's --help | 2026-10-19 | 86c5a80 | [261019-05g-phase-17-verify-work-unblock-coverage-md](./quick/261019-05g-phase-17-verify-work-unblock-coverage-md/) |
| 261019-hgw | Translate hook ValidationError phrase keys in the request locale | 2026-10-19 | ce8d7ef | [261019-hgw-translate-hook-validationerror-phrase-ke](./quick/261019-hgw-translate-hook-validationerror-phrase-ke/) |
## Deferred Items

View File

@@ -19,7 +19,7 @@ Out-of-scope findings logged during execution. None blocks the plan that found i
## From 17-04 (FAQ and menu plugins)
1. **Hook validation messages reach the admin as phrase keys.** A `cabana.ValidationError` returned from a form hook (`FormBeforeCreate`, `FormAfterCreate`, …) is written as-is by `writeCRUDError` (`modules/cabana/crud.go`), while `cabana.ForbiddenError` messages and details are translated in the request locale. The admin SPA prints field errors raw (`FormField.vue` renders `{{ text }}`; `FormErrorBanner.vue` lists orphans raw), so an editor sees `golem15.menu::lang.errors.url_scheme` instead of the sentence. Affects the FAQ `errors.locale_required` and the menu `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required` refusals. Follow-up in the framework: translate `ValidationError.Details` phrase keys in the request locale like `ForbiddenError` (server side), so every client gets text.
1. **Hook validation messages reach the admin as phrase keys.** A `cabana.ValidationError` returned from a form hook (`FormBeforeCreate`, `FormAfterCreate`, …) is written as-is by `writeCRUDError` (`modules/cabana/crud.go`), while `cabana.ForbiddenError` messages and details are translated in the request locale. The admin SPA prints field errors raw (`FormField.vue` renders `{{ text }}`; `FormErrorBanner.vue` lists orphans raw), so an editor sees `golem15.menu::lang.errors.url_scheme` instead of the sentence. Affects the FAQ `errors.locale_required` and the menu `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required` refusals. Follow-up in the framework: translate `ValidationError.Details` phrase keys in the request locale like `ForbiddenError` (server side), so every client gets text. **Resolved 2026-10-19 by ce8d7ef (quick 261019-hgw).**
2. **Public API misses log once per request.** The menu API logs each left-out entry (unregistered type, unresolvable reference) at warn level on every request; with a stale entry in a busy menu that repeats per request (bounded by the 120 per minute throttle per client). Acceptable for now; a later change could log once per entry and boot.
## From 17-05 (site integration)

View File

@@ -0,0 +1,225 @@
---
phase: quick-261019-hgw
plan: 01
quick_id: 261019-hgw
slug: translate-hook-validationerror-phrase-ke
type: execute
mode: quick
wave: 1
depends_on: []
files_modified:
- modules/cabana/crud.go
- modules/cabana/relation.go
- modules/cabana/actions.go
- modules/cabana/field_type.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/relation_field.go
- modules/cabana/localize_error_test.go
- modules/cabana/phase121_fixture_test.go
- modules/cabana/phase121_forbidden_test.go
- modules/cabana/reorder_hook_test.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/backend/partials-and-widgets.md
- docs/backend/lists-and-filters.md
autonomous: true
requirements: [17-W-1]
source:
- orchestrator brief (Phase 17 deferred item 1 / verifier W-1)
- .planning/phases/17-blog-companions/17-VERIFICATION.md row W-1 (raw `golem15.menu::lang.errors.url_scheme` shown on refused saves)
- .planning/phases/17-blog-companions/deferred-items.md item 1
- modules/cabana/crud.go:64-135 (ValidationError, ForbiddenError, the ForbiddenError-only localizer, CRUDService.transaction), :369-373 (detached bulk), :825-860 (writeCRUDError)
- modules/cabana/form_schema.go:403-433 (schemaLocale, translateKey), modules/phrasebook/translator.go:153-160 and loader.go:126,233-243 (GetIn returns the key unchanged when absent; every catalog key is `vendor.plugin::group.path`)
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "A form hook that returns a cabana.ValidationError whose Details hold a phrase key answers 422 validation_failed with that message translated in the request locale: the roster fixture's PUT of name Rejected gives `This name is reserved.` under Accept-Language en and `Ta nazwa jest zastrzeżona.` under pl (Postgres test TestValidationErrorLocalized)."
- "The same translation applies on every route that carries plugin-authored errors: form hooks and relation hooks (both service transactions), locked and Detached bulk actions, record actions, toolbar and widget actions (runAction), the pact.ReorderAfterSave hook, the pact.AdminSingleton SingletonRecord call and the cabana.RelationLockProvider on the relation-options route; the reorder and singleton routes now also localize a cabana.ForbiddenError, which they wrote raw before."
- "Text that is not a phrase key (lagoon rule messages already translated in the request locale, framework strings such as `The request body is invalid.`) reaches the client byte-identical and is never looked up in the catalog, so the development log gets no `phrasebook: missing translation key` warning for it."
- "The plugin's error value is never modified: a shared `*cabana.ValidationError` or `*cabana.ForbiddenError` variable keeps its keys after any number of requests in any locale (unit test and the roster Postgres test)."
- "cabana.ForbiddenError behaviour is unchanged: TestForbiddenLocalized, TestForbiddenFromEveryHook, TestForbiddenEmptyMessage and TestForbiddenSmoke stay green; an empty Message stays empty; every other error is still the opaque 500."
- "modules/cabana/README.md and docs/backend/admin-controllers.md, partials-and-widgets.md and lists-and-filters.md say that ValidationError messages may be translation keys translated in the request locale; `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass."
- "`go vet ./...` and `go test -short ./...` are green; the change lands as one fix(cabana) commit staged by explicit path, without a Co-Authored-By trailer; no plugin, application, tag or push."
artifacts:
- "modules/cabana/crud.go — localizeControllerError (ValidationError then ForbiddenError, same order as writeCRUDError), localizeDetails (one shared copy-on-write loop) and localizeDetail (translates only strings containing `::`); the ForbiddenError-only localizer is gone"
- "modules/cabana/localize_error_test.go — Docker-free unit tests (package cabana) on a two-locale fstest catalog"
- "modules/cabana/phase121_fixture_test.go — shared rosterInvalid ValidationError returned by FormBeforeUpdate for the name Rejected"
- "modules/cabana/phase121_forbidden_test.go — TestValidationErrorLocalized (Postgres)"
- "modules/cabana/reorder_hook_test.go — hookShelfPlugin.LangFS and TestReorderHookRefusalLocalized (Postgres)"
key_links:
- "CRUDService.transaction (crud.go:133) and RelationService.transaction (relation.go:873) wrap lagoon.Transaction; every form, relation, bulk and record hook error leaves the services through them, so switching both wrappers to localizeControllerError covers the menu (FormBeforeCreate/FormBeforeUpdate) and FAQ refusals named in W-1."
- "writeCRUDError (crud.go:825) has no context or translator and checks *ValidationError before *ForbiddenError; localization therefore happens at the service and route boundaries, and the helper must test the two types in the same order so the error it localizes is the one writeCRUDError writes."
- "reorderPost (reorder.go:319) and singletonRoute (singleton.go:~90) open their own transactions (lagoon.Transaction, db.Transaction) and pass hook errors through lifecycleFailure straight to writeCRUDError: these are the two boundaries that localized neither error type before this task."
- "The `::` guard is output-preserving: phrasebook builds every key as `pluginID + \"::\" + group` (loader.go:126) or `namespace::group` (overrides), and Catalog.lookup is an exact map lookup, so a string without `::` can never resolve; skipping it only avoids Translator.logMissing."
---
<objective>
Translate the phrase keys in a plugin-authored `cabana.ValidationError` in the request locale before the admin API writes it, exactly as `cabana.ForbiddenError` already is, on every route that can carry such an error.
Purpose: shared plugins (menu: `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required`; FAQ: `errors.locale_required`) refuse saves with `&cabana.ValidationError{Details: map[string]any{"field": []string{"golem15.menu::lang.errors.url_scheme"}}}`. writeCRUDError writes `ve.Details` as given and the admin SPA renders error strings raw (FormField.vue, FormErrorBanner.vue), so editors see `golem15.menu::lang.errors.url_scheme` instead of the sentence (17-VERIFICATION W-1). The fix belongs in the framework, server side, so every client gets text.
Output: one shared localizer for both refusal types in cabana, applied at every boundary (two service transactions, the detached bulk branch, runAction, reorder, singleton, relation options), Docker-free unit tests plus two Postgres tests, README and docs updates, all in one fix(cabana) commit.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@modules/cabana/crud.go
@modules/cabana/README.md
@docs/backend/admin-controllers.md
Live state observed at planning time (2026-10-19), HEAD d785958, re-check where a task says so:
- `git status --short -- modules/cabana docs` was empty; only `.msd/` is untracked in the repo.
- crud.go:86-127 is the ForbiddenError-only localizer: it sets the locale with `towel.WithLocale(ctx, schemaLocale(ctx, tr))`, builds a NEW ForbiddenError (Message through translateKey; Details copied with string -> []string{t}, []string -> new slice, []any -> new slice translating strings only, anything else passed through) and never writes to the plugin's value. Its callers are exactly four: crud.go:134 (CRUDService.transaction), crud.go:372 (detached bulk branch), relation.go:873 (RelationService.transaction), actions.go:290 (runAction). field_type.go:775 names it in a comment.
- actions.go:279-292 runAction: a `*ValidationError` goes to writeCRUDError untranslated; a `*ForbiddenError` is localized first.
- reorder.go:317-363: `ctx := towel.WithLocale(...)` then `err = lagoon.Transaction(ctx, db, ...)`; the ReorderAfterSave hook error goes through lifecycleFailure; `s.writeReorderError(w, r, cc, err)` ends in writeCRUDError. Neither error type is localized there. The menu plugin implements ReorderAfterSave.
- singleton.go:~62-105: `ctx := r.Context()`, `db.WithContext(ctx).Transaction(attempt)`; a SingletonRecord error goes through lifecycleFailure and then `writeCRUDError(w, err)`. Neither type is localized.
- relation_field.go:~389: CRUDService.RelationOptions returns the relationLocks error (AdminRelationLocks provider, lifecycleFailure-wrapped) unlocalized; the save path's checkRelationLocks runs inside CRUDService.transaction and is covered by it.
- field_file.go and field_type_upload.go open their own transactions but run no hook that can return a plugin ValidationError (plugin field types answer with pact.FormFieldError, already localized by fieldTypeFailure; other plugin errors become the opaque 500). account*.go errors are framework-authored and already translated.
- Translator.GetIn returns the key unchanged when no locale in the fallback chain has it, and in non-production calls logMissing (one slog.Warn per distinct key per translator). lagoon.Validate messages are already translated text; without a guard, every distinct rule message would be logged as a missing key in development.
- Test fixtures: the roster fixture (phase121_fixture_test.go) loads lang/en and lang/pl from testdata/roster; keys `acme.roster::lang.people.refused_name` = `This name is reserved.` / `Ta nazwa jest zastrzeżona.`; FormBeforeUpdate switches on the name (Reserved, Silent, Boom); the name Rejected is unused in cabana tests. rosterLocale sets Accept-Language; rosterError decodes cabana.ErrorEnvelope into cabana.ErrorBody {Code, Message, Details}. The shelf hook env (reorder_hook_test.go) assembles through paymentsAssemble, which calls phrasebook.Activate(app, plugins), so a `LangFS() fs.FS` method on hookShelfPlugin loads `acme.shelf::lang.*` keys; the app locale is en. TestReorderHookErrorRollsBack already covers plain-text ValidationError ("Refused.") and ForbiddenError ("Locked.").
- account_preferences_internal_test.go shows the Docker-free translator pattern: phrasebook.NewCatalog, cat.Load(pluginID, fstest.MapFS{...}), phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}).
Repo rules (CLAUDE.md): framework code only, no plugin or application change; a behaviour change to an exported type updates the module README and docs pages in the same commit; every identifier on a docs page must exist (docs checker); no unexported helper names in README/docs; stage by explicit path, never `git add -A`; leave other sessions' edits (STATE.md, state.json, `.msd/`) alone; no Co-Authored-By trailer (user instruction overrides the attribution reminder); no push, no tag; Docker-backed tests skip under -short, and if the Docker socket is denied run them through `sudo -u golem`.
</context>
<!-- planner-discipline-allow: localizeForbidden -->
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Shared refusal localizer, wired end-to-end on the form-hook save path</name>
<files>modules/cabana/crud.go, modules/cabana/relation.go, modules/cabana/actions.go, modules/cabana/field_type.go, modules/cabana/localize_error_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_forbidden_test.go</files>
<precondition>`git status --short -- modules/cabana docs` prints nothing (no other session has in-flight edits in cabana or docs); if it does, stop and report instead of editing.</precondition>
<behavior>
- Unit (localize_error_test.go, package cabana, translator built from fstest.MapFS for plugin acme.menu with lang/en/lang.yaml and lang/pl/lang.yaml defining errors.url_scheme and errors.target): a ValidationError whose Details are {"url": []string{"acme.menu::lang.errors.url_scheme"}, "target": "acme.menu::lang.errors.target" (bare string), "mixed": []any{"acme.menu::lang.errors.url_scheme", 7}, "title": []string{"The title field is required."}, "nested": map[string]any{"pl": []string{"acme.menu::lang.errors.url_scheme"}}} comes back, under towel.WithLocale "pl" and then "en", as url -> []string{translated}, target -> []string{translated}, mixed -> []any{translated, 7}, title unchanged, nested unchanged (passed through like the ForbiddenError loop always did).
- Unit: an unknown phrase key (acme.menu::lang.errors.missing) comes back unchanged.
- Unit: the input value is not modified (deep-compare against a copy taken before the call; the result is a different pointer, and its slices are new); localizing the same shared value twice in different locales gives each locale's text.
- Unit: nil -> nil; errors.New("x") -> the identical value; a ValidationError with nil or empty Details -> the identical value; fmt.Errorf("save: %w", ve) -> a localized *ValidationError.
- Unit (regression): a ForbiddenError with Message and Details phrase keys is localized in en and pl as before; an empty Message stays empty; the plugin's value is untouched.
- Unit (envelope): writeCRUDError on an httptest.ResponseRecorder with the localized pl error answers 422, code validation_failed, details.url == ["<pl text>"].
- Unit (guard): with slog.SetDefault swapped for a text handler on a bytes.Buffer (restored in t.Cleanup; this test does not call t.Parallel) and a fresh translator, localizing a ValidationError whose only message is plain text leaves no `missing translation key` in the buffer, while one with an unknown `::` key does log it (positive control proving the capture works).
- Postgres (TestValidationErrorLocalized in phase121_forbidden_test.go): PUT name Rejected under Accept-Language en answers 422, code validation_failed, details {"name": ["This name is reserved."]}; under pl, ["Ta nazwa jest zastrzeżona."]; afterwards the shared rosterInvalid still holds the key.
</behavior>
<action>
RED first: write localize_error_test.go and the Postgres test from the behavior list, run them, and confirm they fail (the unit file fails to compile until the helper exists; the Postgres test sees the raw key).
Then in crud.go replace the ForbiddenError-only localizer (lines 86-127) with three unexported functions, keeping its doc-comment guarantees:
- localizeControllerError(ctx context.Context, tr *phrasebook.Translator, err error) error. nil and any error that is neither type are returned unchanged. Test the types in writeCRUDError's order: first errors.As to *ValidationError (non-nil pointer, len(Details) > 0, else return err as is) and return a NEW &ValidationError{Details: localizeDetails(...)}; then *ForbiddenError, built exactly as today (Message through translateKey so an empty Message stays empty, Details through localizeDetails when non-empty, nil otherwise). Keep the nil-ctx guard and set the locale once with towel.WithLocale(ctx, schemaLocale(ctx, tr)).
- localizeDetails(ctx, tr, details map[string]any) map[string]any: the one shared copy-on-write loop both types use (string -> []string{localized}; []string -> new slice; []any -> new slice, strings localized, other items kept; any other value passed through untouched). It allocates a new map and new slices and never writes to the input.
- localizeDetail(ctx, tr, text string) string: returns text unchanged unless it contains "::", else translateKey. Its comment states why: every phrasebook key is vendor.plugin::group.path and lookup is exact, so text without "::" can never resolve; skipping it keeps already-translated lagoon rule messages byte-identical and keeps the development log free of a missing-key warning per rule message. The guard is output-identical for ForbiddenError.
The plugin's error value is never modified (it may be a shared package variable used by concurrent requests); say so in the comment.
Update the ValidationError doc comment (crud.go:64): Details maps a field to messages, each a phrase key or text; keys are translated in the request locale before the response is written, like ForbiddenError.
Switch all four existing callers to localizeControllerError and update their comments: CRUDService.transaction (crud.go:130-135, now "localizes a *ValidationError or *ForbiddenError that comes out of it"), the Detached bulk branch (crud.go:372), RelationService.transaction (relation.go:871-874). In actions.go runAction (lines 272-292) collapse the two branches into one: when err is a *ValidationError or a *ForbiddenError, writeCRUDError(w, localizeControllerError(ctx, tr, err)) and return; update the doc comment above runAction (a 422 and a 403, both localized). Update the comment at field_type.go:775 to name localizeControllerError.
Fixture: in phase121_fixture_test.go add, next to rosterRefused, a shared `rosterInvalid = &cabana.ValidationError{Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}}` with a comment (the framework must localize a copy, never this value), and a `case "Rejected": return rosterInvalid` in rosterController.FormBeforeUpdate; extend that method's doc comment. Reuse the existing refused_name key; do not touch testdata lang files.
Postgres test: TestValidationErrorLocalized in phase121_forbidden_test.go with newRosterEnv, rosterInsert, rosterLocale(env, PUT, rosterPath(id, ""), `{"name":"Rejected"}`, locale) and rosterError, modelled on TestForbiddenLocalized; finish by asserting rosterInvalid.Details["name"] still equals []string{"acme.roster::lang.people.refused_name"}.
GREEN: run the unit tests and the Postgres tests until they pass. Do NOT commit; Task 3 commits the whole change together with the docs (one logical change, CLAUDE.md docs-in-the-same-commit rule).
</action>
<verify>
<automated>go vet ./modules/cabana && go test -short ./modules/cabana -run 'TestLocalizeControllerError|TestWriteCRUDErrorLocalized' -count=1 && go test ./modules/cabana -run 'TestValidationErrorLocalized|TestForbiddenLocalized|TestForbiddenFromEveryHook|TestForbiddenEmptyMessage|TestForbiddenSmoke' -count=1 && ! grep -rn --include=*.go 'localizeForbidden' modules/ && test "$(grep -c 'localizeControllerError(' modules/cabana/actions.go)" -ge 1</automated>
</verify>
<done>The unit tests (every behavior bullet, en and pl) and TestValidationErrorLocalized pass; the four ForbiddenError Postgres tests stay green (if the Docker socket is denied they were run through `sudo -u golem`); no Go file mentions the old localizer name; runAction localizes both error types; nothing committed yet.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Localize refusals on the reorder, singleton and relation-options routes, and audit every remaining path</name>
<files>modules/cabana/reorder.go, modules/cabana/singleton.go, modules/cabana/relation_field.go, modules/cabana/reorder_hook_test.go</files>
<behavior>
- Postgres (TestReorderHookRefusalLocalized in reorder_hook_test.go): with hookShelfPlugin serving lang/en/lang.yaml for acme.shelf (errors.order, errors.locked), a ReorderAfterSave hook returning &cabana.ValidationError{Details: {"nodes": []string{"acme.shelf::lang.errors.order"}}} answers 422 whose body carries the English sentence and not the key; one returning &cabana.ForbiddenError{Message: "acme.shelf::lang.errors.locked"} answers 403 with the English sentence as message; the sort values are unchanged in both cases.
- TestReorderHookErrorRollsBack, TestReorderHookRunsInTx and TestReorderWithoutHookUnchanged stay green (the plain-text "Refused." and "Locked." still come back as written).
</behavior>
<action>
RED first: add `LangFS() fs.FS` to hookShelfPlugin in reorder_hook_test.go returning an fstest.MapFS with lang/en/lang.yaml defining errors.order and errors.locked (two short English sentences), then write TestReorderHookRefusalLocalized from the behavior list with newShelfHookEnv and env.swap; run it and see the raw keys come back.
Then wire the three boundaries that localize nothing today (each a one-line change plus a comment line):
- reorder.go reorderPost: right after the lagoon.Transaction call returns, set err = localizeControllerError(ctx, s.translator(), err) before the existing `if err != nil { s.writeReorderError(...) }`; ctx is the locale context declared above the transaction. writeReorderError keeps its logging rules unchanged. Update the reorderPost doc comment: a ValidationError or ForbiddenError from ReorderAfterSave is answered localized.
- singleton.go singletonRoute: in the final error branch, write writeCRUDError(w, localizeControllerError(ctx, s.translator(), err)); the slog.Error line stays as is.
- relation_field.go CRUDService.RelationOptions: when relationLocks returns an error, return localizeControllerError(ctx, s.tr, err) in its place.
Audit (report-only unless a gap is found): list every `Transaction(` call in modules/cabana/*.go outside _test.go, and every lifecycleFailure or actionFailure call whose argument is the error of a plugin-implemented method (pact.* hook, AdminAction Run/Applies, RelationLockProvider, AdminSingleton). For each, record in the SUMMARY which boundary localizes it (CRUDService.transaction, RelationService.transaction, the detached branch, runAction, reorderPost, singletonRoute, RelationOptions) or why it cannot carry a plugin-authored ValidationError/ForbiddenError (framework-only error, pact.FormFieldError path, opaque 500). If a path is found that writes a plugin-authored ValidationError or ForbiddenError to the client unlocalized and has a translator in reach, wrap it with localizeControllerError in this task and list it; a read-only path where that would need new plumbing is listed as a finding instead.
Do NOT commit; Task 3 commits.
</action>
<verify>
<automated>go vet ./modules/cabana && go test -short ./modules/cabana -count=1 && go test ./modules/cabana -run 'TestReorderHook|TestReorderWithoutHookUnchanged' -count=1 && for f in reorder singleton relation_field; do test "$(grep -c 'localizeControllerError(' modules/cabana/$f.go)" -ge 1 || { echo "missing in $f.go"; exit 1; }; done</automated>
</verify>
<done>reorderPost, singletonRoute and RelationOptions pass their hook errors through localizeControllerError; TestReorderHookRefusalLocalized and the existing reorder hook tests pass on Postgres; the audit table (path -> covering boundary or reason) is ready for the SUMMARY; nothing committed yet.</done>
</task>
<task type="auto">
<name>Task 3: Document the localized ValidationError, run every gate, commit once</name>
<files>modules/cabana/README.md, docs/backend/admin-controllers.md, docs/backend/partials-and-widgets.md, docs/backend/lists-and-filters.md</files>
<action>
Docs (no unexported helper names, only identifiers that exist; keep the existing voice and sentence style):
- modules/cabana/README.md line 55 (Refusals bullet): add that a cabana.ValidationError returned from the same places, and from a pact.ReorderAfterSave hook or a pact.AdminSingleton SingletonRecord call, is answered 422 validation_failed with each Details message that is a translation key (`vendor.plugin::group.key`) translated in the request locale, text passing through unchanged; add the reorder hook and singleton record to the places a ForbiddenError is localized. Table row at line 311 (ValidationError / ListValidationError): a hook or an action may return a cabana.ValidationError, whose Details messages may be translation keys, translated in the request locale. Testing section (line 378 on): one sentence naming localize_error_test.go (Docker-free unit tests of the shared localization) and the Postgres tests TestValidationErrorLocalized and TestReorderHookRefusalLocalized.
- docs/backend/admin-controllers.md, section "Refusing a write" (lines 265-278): the ValidationError table row gains "each message that is a translation key is translated in the request locale"; the last paragraph's "A cabana.ForbiddenError works the same from ..." covers both errors and adds the reorder hook (pact.ReorderAfterSave) and the singleton record (pact.AdminSingleton). Add one short example sentence: a hook can return a field message as a phrase key such as `acme.blog::lang.errors.slug_taken` and the administrator sees the sentence in their language. Line 444 (bulk action Run): a cabana.ValidationError is a 422 with its messages translated.
- docs/backend/partials-and-widgets.md line 73: after "An action may return a cabana.ValidationError to answer 422 on a field." add that its messages may be translation keys, translated in the request locale.
- docs/backend/lists-and-filters.md line 287: after "A returned error rolls the whole reorder back." add that a cabana.ValidationError is answered 422 and a cabana.ForbiddenError 403, both with their messages translated in the request locale, and any other error is the opaque 500.
Use neutral names (acme, blog); never name a consuming application or the menu/FAQ plugins.
Gates, in order, fixing anything red before moving on: go vet ./... ; go test -short ./... ; go test ./cmd/summer -run TestDocsTree ; go run ./cmd/summer docs:build --check ; the Postgres set from Tasks 1 and 2 once more (TestValidationErrorLocalized, TestForbidden*, TestReorderHook*, TestReorderWithoutHookUnchanged), through `sudo -u golem` if the Docker socket is denied (if Docker is unavailable entirely, say so in the SUMMARY; the -short gates still must pass).
Commit: re-run `git status --short` and stage ONLY the files of this plan's files_modified list that actually changed, by explicit path (never `git add -A`/`git add .`; leave `.msd/`, STATE.md, state.json and any other session's edits alone). One commit, subject `fix(cabana): translate hook ValidationError phrase keys in the request locale`, a short body (shared localizer for ValidationError and ForbiddenError; applied on the service transactions, actions, reorder, singleton and relation-options routes; text without `::` passes through unchanged; plugin values never modified; docs). No Co-Authored-By or other attribution trailer. No push, no tag.
</action>
<verify>
<automated>go vet ./... && go test -short ./... && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && grep -q 'translation key' docs/backend/admin-controllers.md && C=$(git log -1 --format=%H --fixed-strings --grep='fix(cabana): translate hook ValidationError phrase keys in the request locale') && test -n "$C" && BODY=$(git log -1 --format=%B "$C") && ! printf '%s\n' "$BODY" | grep -qi 'co-authored-by' && FILES=$(git diff --name-only "$C^..$C") && test -n "$FILES" && test -z "$(printf '%s\n' "$FILES" | grep -v -E '^(modules/cabana/|docs/backend/)')"</automated>
</verify>
<done>README and the three docs pages describe the localized ValidationError (and the reorder and singleton refusals); vet, short tests, docs tree test and docs:build --check pass; the Postgres set passed (or Docker unavailability is recorded); the single fix(cabana) commit (found by subject) touches only modules/cabana/ and docs/backend/ files, with no attribution trailer; nothing pushed or tagged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| plugin controller code -> cabana error writer | plugin-authored ValidationError/ForbiddenError values (often shared package variables) cross into the framework's response path on concurrent requests |
| admin API response -> admin client | localized messages are written into the 422/403 JSON envelope read by the admin SPA and any other client |
| request Accept-Language -> translator | the client chooses the locale the messages are translated into |
## STRIDE Threat Register
Numbering continues after the highest Phase 17 ID in use (T-17-44, registered by quick-261019-05g).
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-45 | Tampering | localizeControllerError / localizeDetails on shared plugin error values | medium | mitigate | Copy-on-write only: a new error, new map and new slices per call, the input never written; unit test deep-compares the input before/after and localizes one shared value in two locales; the roster Postgres test asserts rosterInvalid keeps its key. Prevents a data race and one request's locale leaking into another's response. |
| T-17-46 | Information disclosure | runAction, reorderPost, singletonRoute, RelationOptions error paths | medium | mitigate | Only *ValidationError and *ForbiddenError are rebuilt; every other error is returned unchanged and still becomes the opaque 500 in writeCRUDError, so no foreign error text reaches the client; unit test asserts a plain error is returned as the identical value; existing TestForbiddenSmoke and TestReorderHookErrorRollsBack 500 assertions stay green. |
| T-17-47 | Information disclosure | translating a `::` string a plugin echoes from user input | low | accept | The catalog holds UI strings the admin already downloads through the lang endpoint; a lookup can only return such text or the input unchanged. |
| T-17-48 | Denial of service | development-log volume from missing-key warnings | low | mitigate | localizeDetail looks up only strings containing `::`, so already-translated rule messages and framework text never reach Translator.logMissing; guard test asserts no warning for plain text with a positive control. Production translators do not log missing keys at all. |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager install and no new Go dependency: the change uses only phrasebook, towel, the standard library and existing test helpers. |
</threat_model>
<verification>
- `go vet ./...` and `go test -short ./...` green.
- Docker: TestValidationErrorLocalized, TestReorderHookRefusalLocalized, TestForbiddenLocalized, TestForbiddenFromEveryHook, TestForbiddenEmptyMessage, TestForbiddenSmoke, TestReorderHookErrorRollsBack, TestReorderHookRunsInTx, TestReorderWithoutHookUnchanged green (through `sudo -u golem` if the socket is denied).
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
- `grep -rn --include=*.go 'localizeForbidden' modules/` prints nothing; localizeControllerError appears in crud.go, relation.go, actions.go, reorder.go, singleton.go and relation_field.go.
- The `fix(cabana): translate hook ValidationError phrase keys in the request locale` commit (located by subject, not by HEAD; other sessions may commit in between) touches only modules/cabana/ and docs/backend/ files and carries no attribution trailer; `git status --short` shows no leftover change from this plan (other sessions' files untouched); nothing pushed or tagged; no change outside summercms.go.
</verification>
<success_criteria>
An editor whose save, reorder or action a shared plugin refuses with a phrase-key ValidationError sees the sentence in their admin language instead of `vendor.plugin::lang.errors.*`, with no plugin, application or SPA change; already-translated messages are unchanged; ForbiddenError behaves as before and is now also localized on the reorder and singleton routes.
</success_criteria>
<output>
Create `.planning/quick/261019-hgw-translate-hook-validationerror-phrase-ke/261019-hgw-SUMMARY.md` when done, listing: the commit hash; the audit table from Task 2 (each Transaction/hook path -> covering boundary or reason it cannot carry a plugin refusal) and any additional path wrapped or left as a finding; whether the Postgres tests ran directly or through `sudo -u golem` (or not at all, with the reason); and the new threat rows T-17-45 .. T-17-48 for later registration in Phase 17's security record. Note that consuming applications pick the fix up with their next framework version bump (no tag in this task) and that W-1 in 17-VERIFICATION.md / deferred-items.md item 1 can be marked resolved by that commit.
</output>

View File

@@ -0,0 +1,154 @@
---
phase: quick-261019-hgw
plan: 01
quick_id: 261019-hgw
subsystem: cabana
status: complete
tags: [cabana, phrasebook, i18n, validation, refusals]
requires: [phrasebook.Translator, towel.WithLocale]
provides: [localized cabana.ValidationError details on every admin route that carries plugin refusals]
affects: [modules/cabana, docs/backend]
tech-stack:
added: []
patterns: [copy-on-write error localization at service/route boundaries, "::" phrase-key guard]
key-files:
created:
- modules/cabana/localize_error_test.go
modified:
- modules/cabana/crud.go
- modules/cabana/relation.go
- modules/cabana/actions.go
- modules/cabana/field_type.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/relation_field.go
- modules/cabana/phase121_fixture_test.go
- modules/cabana/phase121_forbidden_test.go
- modules/cabana/reorder_hook_test.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/backend/partials-and-widgets.md
- docs/backend/lists-and-filters.md
decisions:
- "ValidationError Details messages that contain `::` are translated in the request locale at the same boundaries as ForbiddenError; text without `::` is never looked up (output-identical, no missing-key log noise)."
- "ShowRecord's read transaction also localizes refusals (FormRecordTitle, record-action Applies, RelationLockProvider), found in the Task 2 audit."
requirements: [17-W-1]
duration: 9min
completed: 2026-10-19
commits: 1
plan_head_before: d785958df239a463b68bc4570f487f320fe722a9
plan_head_after: ce8d7efb675a15840b5ee274d11625d920827147
actuals:
tokens: 12350
tasks: 3
commits: 1
---
# Quick 261019-hgw: Translate hook ValidationError phrase keys Summary
cabana now translates the phrase keys in a plugin's `cabana.ValidationError` Details into the request locale before writing the 422, the same way it already handled `cabana.ForbiddenError`. One copy-on-write localizer (`localizeControllerError`) handles both error types. It runs at every boundary where a plugin can return an error: the two service transactions, the detached bulk branch, runAction, ShowRecord, reorder, singleton and relation options.
## Commit
- `ce8d7ef` fix(cabana): translate hook ValidationError phrase keys in the request locale (15 files, modules/cabana/ and docs/backend/ only, no attribution trailer, not pushed, not tagged)
## What changed
- `crud.go`: `localizeForbidden` is replaced by:
- `localizeControllerError`, which checks ValidationError first and then ForbiddenError, the same order writeCRUDError uses.
- `localizeDetails`, a shared copy-on-write loop.
- `localizeDetail`, which looks up only strings that contain `::`.
The ValidationError doc comment now describes the localization. CRUDService.transaction, the Detached bulk branch and ShowRecord use the new localizer.
- `relation.go`: RelationService.transaction uses `localizeControllerError`.
- `actions.go`: runAction now has a single branch that localizes both error types.
- `reorder.go`: reorderPost localizes the transaction error before writeReorderError. Neither error type was localized here before.
- `singleton.go`: singletonRoute localizes before writeCRUDError. Neither error type was localized here before.
- `relation_field.go`: RelationOptions localizes the RelationLockProvider error.
- `field_type.go`: the comment now names the new helper.
## Tests
- Unit tests in `localize_error_test.go` (Docker-free, two-locale `fstest` catalog for acme.menu):
- TestLocalizeControllerErrorValidation covers each Details shape (`[]string`, bare string, `[]any` with a non-string item, plain text, a nested map, an unknown key) in pl, en and pl again on one shared value. It checks the result is a different pointer with new slices and that the input is deep-equal before and after.
- TestLocalizeControllerErrorUnchanged covers nil, a plain error, nil or empty Details, a typed-nil `*ValidationError`, a wrapped `%w`, and a nil translator.
- TestLocalizeControllerErrorForbidden is the ForbiddenError regression test (en and pl, empty Message, value untouched).
- TestWriteCRUDErrorLocalized checks the 422 envelope in pl.
- TestLocalizeControllerErrorPlainTextNotLookedUp swaps slog. Plain text logs nothing; the positive control (an unknown `::` key) does log.
- Postgres tests:
- TestValidationErrorLocalized (roster PUT name `Rejected` gives `This name is reserved.` in en and `Ta nazwa jest zastrzeżona.` in pl; the shared `rosterInvalid` keeps its key).
- TestReorderHookRefusalLocalized (hookShelfPlugin.LangFS; ValidationError gives 422 with the sentence, ForbiddenError gives 403 with the sentence; sort values unchanged).
- RED was observed first. The unit file failed to compile, and the Postgres tests returned `acme.roster::lang.people.refused_name` / `acme.shelf::lang.errors.*` raw.
## Gates
- `go vet ./...`: green
- `go test -short ./...`: green
- `go test ./cmd/summer -run TestDocsTree`: green
- `go run ./cmd/summer docs:build --check`: no problems found
- Postgres: ran **directly** (Docker socket accessible, no `sudo -u golem` needed):
- the full `go test ./modules/cabana`: ok, 97s
- the named set: TestValidationErrorLocalized, TestReorderHookRefusalLocalized, TestForbiddenSmoke/FromEveryHook/Localized/RollsBack/EmptyMessage, TestReorderHookRunsInTx, TestReorderHookErrorRollsBack (plain text "Refused."/"Locked." unchanged), TestReorderWithoutHookUnchanged. All PASS.
## Audit: Transaction and plugin-hook paths (Task 2)
| Path | Plugin-authored error source | Covered by / reason |
|------|------------------------------|---------------------|
| crud.go CRUDService.transaction (Delete, BulkDelete, BulkAction locked, RecordAction, save) | Form* hooks (lifecycleFailure), bulk/record action Run and Applies (actionFailure), checkRelationLocks, FormRecordTitle on save | CRUDService.transaction |
| crud.go detachedBulkAction | bulk action Run (Detached) | Detached branch in BulkAction |
| crud.go:512 ShowRecord (`s.DB.Transaction`) | FormRecordTitle, record-action Applies (offeredRecordActions), RelationLockProvider via projectRelationFields | **Gap found and wrapped in this task**: ShowRecord now returns `localizeControllerError(ctx, s.tr, err)` |
| relation.go RelationService.transaction (Link, Unlink, CreateChild/ShowChild/UpdateChild/DeleteChildren, ShowPivot/UpdatePivot) | Relation* hooks, RelationBeforeLink | RelationService.transaction |
| relation.go:625 RelationService.query (Linked/Candidates) | none: RelationExtendManageQuery and FormExtendQuery return `*gorm.DB`, not an error | cannot carry a plugin refusal |
| actions.go runAction (toolbar and widget actions) | AdminAction Run | runAction |
| reorder.go:321 reorderPost (`lagoon.Transaction`) | ReorderAfterSave | reorderPost (new) |
| reorder.go:279 reorderGet | none: ListExtendQuery returns `*gorm.DB` | cannot carry a plugin refusal |
| singleton.go:93/96 singletonRoute | AdminSingleton.SingletonRecord | singletonRoute (new) |
| relation_field.go RelationOptions | RelationLockProvider (AdminRelationLocks) | RelationOptions (new) |
| query.go list (ListExtendRecords, ListRowStates) | plugin hooks returning error | list route writes only the framework `ListValidationError` as 422; every other error is the opaque 500 and is never written raw |
| field_dependent.go dependent options | DependentOptionsProvider | opaque 500 (logged) |
| partial_render.go PartialData | AdminPartialData | opaque 500 (logged) |
| field_file.go:664/733/1004, field_type_upload.go:102 | none: plugin field types answer with pact.FormFieldError (fieldTypeFailure, already localized) | cannot carry a plugin ValidationError/ForbiddenError |
| settings.go:230 SettingsService save | none: no plugin hook in the transaction; ValidationErrors are framework-authored text | text passes through unchanged |
| account.go:322/600/664, account_preferences.go:112 | framework-authored, already translated | not a plugin path |
| commands.go:70/124 | console commands, not HTTP | not an admin API response |
There are no open findings. The one extra gap (ShowRecord) had a translator in reach and is wrapped.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical functionality] ShowRecord read path wrote plugin refusals raw**
- **Found during:** Task 2 audit
- **Issue:** FormRecordTitle, record-action Applies and RelationLockProvider errors on the record GET reached writeCRUDError without localization. The translator `s.tr` was in reach.
- **Fix:** ShowRecord wraps its transaction error with `localizeControllerError` and its doc comment says so. The plan's Task 2 instruction covers this case ("wrap it ... and list it").
- **Files modified:** modules/cabana/crud.go (already in files_modified)
- **Commit:** ce8d7ef
Minor: the ForbiddenError Message now goes through `localizeDetail` (the `::` guard) instead of `translateKey` directly. This is output-identical, because a string without `::` can never resolve, and an empty Message stays empty. The regression tests confirm it.
## Threat Flags / Threat rows for Phase 17 security record
| Threat ID | Category | Component | Disposition | Status |
|-----------|----------|-----------|-------------|--------|
| T-17-45 | Tampering | localizeControllerError / localizeDetails on shared plugin error values | mitigate | Mitigated: new error, map and slices on every call. Covered by the unit deep-compare, two-locale reuse on one shared value, and the `rosterInvalid` Postgres assertion. |
| T-17-46 | Information disclosure | runAction, reorderPost, singletonRoute, RelationOptions, ShowRecord error paths | mitigate | Mitigated: only the two refusal types are rebuilt; any other error is returned as the identical value (unit test) and stays the opaque 500 (TestForbiddenSmoke, TestReorderHookErrorRollsBack plain_error). |
| T-17-47 | Information disclosure | translating a `::` string echoed from user input | accept | Accepted: lookup returns only catalog UI text or the input. |
| T-17-48 | Denial of service | dev-log volume from missing-key warnings | mitigate | Mitigated: `::` guard. TestLocalizeControllerErrorPlainTextNotLookedUp has a positive control. |
No new security surface outside the threat model.
## Known Stubs
None.
## Follow-ups
- Consuming applications get the fix when they next bump the framework version. No tag was made in this task.
- W-1 in `.planning/phases/17-blog-companions/17-VERIFICATION.md` and item 1 in `deferred-items.md` can be marked resolved by `ce8d7ef`.
## Self-Check: PASSED
- FOUND: modules/cabana/localize_error_test.go
- FOUND: commit ce8d7ef (subject matches, files limited to modules/cabana/ and docs/backend/, no co-authored-by)
- `grep -rn localizeForbidden modules/ --include=*.go`: empty. `localizeControllerError(` appears in crud.go, relation.go, actions.go, reorder.go, singleton.go and relation_field.go.