|
|
|
|
@@ -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>
|