Files
summercms/.planning/phases/10-admin-vue-spa/10-02-PLAN.md
2026-09-27 14:11:07 +02:00

335 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 10-admin-vue-spa
plan: 02
type: execute
wave: 2
depends_on: [10-01]
files_modified:
- pact/capabilities.go
- phrasebook/lang.go
- phrasebook/loader.go
- phrasebook/translator.go
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/backend/lang/pl/lang.yaml
- phrasebook/phase10_test.go
- cabana/relation_field.go
- cabana/relation_field_test.go
- cabana/form_schema.go
- cabana/form_schema_test.go
- cabana/list_schema.go
- cabana/list_schema_test.go
- cabana/filter_schema.go
- cabana/relation.go
- cabana/relation_test.go
- cabana/registry.go
- cabana/crud.go
- cabana/http.go
- cabana/messages.go
- cabana/lang.go
- cabana/messages_test.go
- cabana/filter_options_test.go
- cabana/openapi_conformance_test.go
- cabana/admin_openapi.go
- cabana/security_coverage_test.go
- cabana/phase09_contract_test.go
- internal/build/stubs/artifacts.tmpl
- internal/tools/swagger2openapi/main.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/views/ListView.vue
- admin/src/state/useAuth.ts
- admin/src/state/useNavigation.ts
- boardwalk/dist/**
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-17, GET {prefix}/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/options?search=&page=&per_page= returns rows {value: numeric id, label: nameFrom value} with D-11 list meta, behind the controller permission, narrowed by the optional RelationExtendOptionsQuery hook; non-relation and read-only fields answer 404."
- "Per D-18, saving an album with {\"genre\": 3, \"artists\": [4, 9]} sets genre_id and replaces the album's artist pivot rows in array order inside the save transaction after FormBeforeCreate/FormBeforeUpdate; show, create and update responses carry the same shape in data plus meta.labels with {value, label} per relation field."
- "Per D-18 and Pitfall 6, a submitted relation id that the scoped options query would not return (unknown, out of scope, or duplicated) yields 422 validation_failed on that field and rolls back the whole save, including scalar changes."
- "Per D-26, the Collections owner field is served with readOnly true, its options endpoint answers 404, a submitted owner value never changes owner_id, and protectedFillKey and FormBeforeCreate are unchanged."
- "Per D-20, GET {prefix}/api/v1/lang (public) returns every backend::lang key for the request locale as a CLDR form map ({\"other\": ...} for plain strings) with meta.locale; a plugin implementing pact.HasLangOverrides can replace a key or add a locale without a Node rebuild."
- "Per D-13/D-24, config_list, config_form and config_relation accept an optional messages block of phrase keys whose texts use :count, :name and :term placeholders; every served schema carries a complete resolved messages object with defaults filled and plural keys as all their CLDR forms; an unknown messages key fails at boot."
- "Per D-14, toolbar.buttons is an ordered list of create and delete; the Winter string form (buttons: list_toolbar) fails at boot with a message naming the list syntax; delete without showCheckboxes, duplicates and unknown actions fail at boot; the five fonoteka controllers declare [create, delete]."
- "Per D-27, a model-backed filter scope's choices come from the model's FilterOptions(scope) and are served at {prefix}/api/v1/{vendor}/{plugin}/{controller}/filters/{scope}/options behind the controller permission; a scope filter whose model lacks FilterOptions fails at boot."
- "Per D-15/D-16, every admin route in admin/openapi/admin.json has a typed success schema, jsonScalar and fieldContext are emitted as unions, and TestPhase10OpenAPIConformance decodes each real handler response into its documented Go type with unknown fields disallowed."
- "Per D-08, TestPhase10Controllers reads the list schema, list, form schema and one record of each of Albums, Artists, Collections, Genres and Styles through /plytadmin/api/v1 with a cookie, and the fields and columns match exactly what the tracked YAML declares."
- statement: "[flagged assumption A8] The artist pivot sort_order is set to the submitted array index; the API is not a parity surface."
verification: backstop
- statement: "[flagged decision] fonoteka implements no RelationExtendOptionsQuery because golem15_fonoteka_genres and golem15_fonoteka_artists carry no collection column; the hook is proven with cabana acme fixtures."
verification: backstop
- statement: "[flagged decision] Required relation fields keep Phase 9 decision 304: required is a schema hint rendered by the SPA, not a save-time rule, matching the PHP Album rules."
verification: backstop
artifacts:
- path: "cabana/relation_field.go"
provides: "FieldRelationContract compile, options query, id revalidation, FK assignment, ordered pivot sync and labels"
- path: "cabana/messages.go"
provides: "Typed list/form/relation messages blocks with framework defaults and CLDR form resolution"
- path: "cabana/lang.go"
provides: "Public backend::lang string bundle handler"
- path: "phrasebook/backend/lang/pl/lang.yaml"
provides: "Framework Polish admin strings"
- path: "cabana/openapi_conformance_test.go"
provides: "Doc-versus-wire conformance for every admin route"
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go"
provides: "Five-controller assembled contract through the prefix"
key_links:
- from: "cabana/crud.go"
to: "cabana/relation_field.go"
via: "save applies present relation keys after the Before hook and before the row write; show projects values and labels"
pattern: "FieldRelation"
- from: "cabana/relation_field.go"
to: "pact.RelationExtendOptionsQuery"
via: "the same scoped query serves options and revalidates submitted ids"
pattern: "RelationExtendOptionsQuery"
- from: "phrasebook/translator.go"
to: "cabana/lang.go"
via: "Translator.Bundle(locale, \"backend::lang.\") of Forms maps"
pattern: "Bundle"
- from: "cabana/admin_openapi.go"
to: "admin/src/api/schema.d.ts"
via: "scripts/check-admin-openapi.sh, run at the end of each of Tasks 1-3 so every task commits a drift-clean document and types"
pattern: "Envelope"
prohibitions:
- "[flagged-unverified] A relation save must not write a foreign key in the protected fill-key set, and must not attach a related row that the scoped options query would not return."
- "[flagged-unverified] The public string bundle must not expose any namespace other than backend::lang."
- "[flagged-unverified] Framework code must not name a plugin table, pivot, foreign key or label column; they come only from the controller's field relation contract."
- "[flagged-unverified] Existing Phase 9 security assertions (no collection_id or user_id leak, owner forced by FormBeforeCreate) must not be weakened to make relation JSON pass."
---
## Phase Goal
**As a** backend administrator, **I want to** open my project's own admin URL, log in and manage Albums, Artists, Collections, Genres and Styles through schema-driven lists, forms and the relation manager, **so that** I can administer the catalogue from one Go binary without the WinterCMS backend.
<objective>
Grow the `cabana` admin API into the full contract the SPA screens need: relation field options and relation saves with labels, the framework `backend::lang` strings with an override layer and a public bundle, per-controller `messages`, the declarative toolbar, model-backed filter options, and a fully typed OpenAPI document proven against the wire. Update fonoteka's YAML, controllers and lang to the new contract.
Purpose: Plans 10-03 and 10-04 render only what this API serves, so every rule (scoping, read-only owner, plural copy, toolbar actions, filter choices) must be enforced and typed here. Decisions implemented: D-08, D-13, D-14 (costly), D-15, D-16, D-17, D-18, D-20, D-24, D-26, D-27; D-28 fixes this plan's scope.
Output: new cabana files and routes, phrasebook backend namespace and override layer, regenerated admin OpenAPI and TS types, fonoteka YAML/lang/controller updates and assembled tests.
Repos: every task writes summercms.go and fonoteka.go. Commit per repo; planning docs and code in separate commits; never add co-author tags.
Shared OpenAPI files across tasks: `cabana/http.go` and `cabana/admin_openapi.go` are hand-edited and grow additively (Task 1 adds the field options route and `RecordEnvelope`; Task 2 adds `/lang` and the `messages`/`toolbarButtons`/`redirects` schema fields; Task 3 adds the filter options route and types every remaining route). `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are never hand-edited: each task that changes an annotation or a documented Go type regenerates them with `scripts/check-admin-openapi.sh`, commits them in the same commit as that change, and ends drift-clean (`scripts/check-admin-openapi.sh --check` prints no diff) before the next task starts.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-admin-vue-spa/10-CONTEXT.md
@.planning/phases/10-admin-vue-spa/10-RESEARCH.md
@.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md
@cabana/relation.go
@cabana/crud.go
@cabana/form_schema.go
@cabana/list_schema.go
@cabana/filter_schema.go
@phrasebook/loader.go
@phrasebook/translator.go
@pact/capabilities.go
@lagoon/relations.go
<interfaces>
From Plan 10-01: `service.apiBase()`, `adminAPI(rel)` test helpers in cabana and fonoteka, `Envelope[T]`, `ListEnvelope[T]`, `AdminRecord`, `scripts/check-admin-openapi.sh [--check]`, CSRF wrapper on unsafe routes, cookie `summer_admin`.
Existing Phase 9 contracts: `RelationContract` and `AdminRelationContractProvider` (relation manager), `pact.ListRelationColumnMapper`, `pact.FilterScope{FilterScopes(); FilterScope(name, db, value)}` on the model, `pact.DropdownOptionsProvider`, `normalizeIDs`, `escapeLike`, `modelColumns`, `protectedFillKey`, `formBeforeCreate/Update`, `lagoon.Fill`, `lagoon.Validate`, `CRUDService.Show/Create/Update`, `phrasebook.Catalog.Load(namespace, fs)`, `entry{text, plurals, pipes}`, `interpolate` with `:name`, `:Name`, `:NAME`.
Phase 5 join-table contract (lagoon/relations.go): pivot writes are explicit delete then bulk insert in the parent's transaction; never GORM Association().Replace().
</interfaces>
</context>
## Artifacts this phase produces
- `pact.RelationExtendOptionsQuery{ RelationExtendOptionsQuery(ctx, field string, db *gorm.DB) *gorm.DB }`, `pact.HasLangOverrides{ LangOverridesFS() fs.FS }` (layout `lang/<locale>/<namespace>/<group>.yaml`), `pact.FilterOptions{ FilterOptions(scope string) []Option }`
- `cabana.FieldRelationContract{Field, Kind, NewRelated, ForeignKey, NewPivot, ParentForeignKey, RelatedForeignKey, OrderColumn, LabelColumn}`, `cabana.FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract }`, `cabana.RelationOption{Value uint; Label string}`, `cabana.RecordMeta{Labels map[string][]RelationOption}`, `cabana.RecordEnvelope`
- `FormField.Multiple` (`multiple`), `FormField.ReadOnly` (`readOnly`); `FormView.Messages`, `FormView.Redirects`; `ListSchema.Messages`; `RelationSchema.Messages`
- Routes: `GET /{vendor}/{plugin}/{controller}/fields/{field}/options`, `GET /{vendor}/{plugin}/{controller}/filters/{scope}/options`, `GET /lang` (public)
- `phrasebook` namespace `backend` (`phrasebook/backend/lang/{en,pl}/lang.yaml`), `(*Catalog).Override`, `(*Translator).Forms(locale, key) (map[string]string, bool)`, `(*Translator).Bundle(locale, prefix string) map[string]map[string]string`
- `messages` vocabulary: list `recordCount, create, searchPrompt, empty, emptySearch, emptySearchHint, selected, deleteSelected, deleteConfirm, deleted`; form `create, update, saved, deleteConfirm, deleted`; relation `link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty`
- Toolbar compile of `toolbar.buttons` list with boot errors; scaffold stub emits the list syntax
- OpenAPI converter union rewrite for `cabana.jsonScalar` and `cabana.fieldContext`
- fonoteka: `AdminFieldRelations()` on albums (genre, artists) and collections (owner); `messages` and `toolbar.buttons` in all five configs; new lang keys; tests `TestPhase10AlbumRelations`, `TestPhase10CollectionOwnerReadOnly`, `TestPhase10ControllerCopy`, `TestPhase10Controllers`
- cabana tests `TestPhase10RelationOptions`, `TestPhase10RelationSave`, `TestPhase10RelationForgedID`, `TestPhase10RelationBoot`, `TestPhase10Messages`, `TestPhase10Toolbar`, `TestPhase10Bundle`, `TestPhase10FilterOptions`, `TestPhase10OpenAPIConformance`; phrasebook `TestPhase10Forms`, `TestPhase10LangOverride`, `TestPhase10SPAKeysResolve`
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: An admin picks an album's genre and artists from the options endpoint, saves them and reads them back with labels</name>
<files>pact/capabilities.go, cabana/relation_field.go, cabana/relation_field_test.go, cabana/form_schema.go, cabana/form_schema_test.go, cabana/registry.go, cabana/crud.go, cabana/http.go, cabana/admin_openapi.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_albums_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go</files>
<read_first>cabana/relation.go, cabana/crud.go, cabana/form_schema.go, cabana/registry.go, cabana/http.go, cabana/query.go, cabana/admin_openapi.go, pact/capabilities.go, lagoon/relations.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album/fields.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/fields.yaml, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 5, Pitfalls 6 and 13)</read_first>
<behavior>
- TestPhase10RelationOptions (acme fixtures, real PostgreSQL): search is case-insensitive on the label column with LIKE metacharacters escaped; order is label then id; per_page defaults to 20 and caps at 100; RelationExtendOptionsQuery narrows the rows; values are numbers; a principal without the controller permission gets 403 before SQL; a non-relation field and a read-only field get 404.
- TestPhase10RelationSave: belongsTo sets the foreign key; belongsToMany replaces pivot rows in submitted order with OrderColumn equal to the index; a missing key leaves the relation unchanged; null clears a nullable belongsTo; show returns ids plus meta.labels.
- TestPhase10RelationForgedID: an id outside the scoped query, a non-integer id and a duplicated id each return 422 on that field and nothing (scalar or pivot) is committed.
- TestPhase10RelationBoot: a relation field without a contract, a contract naming a missing column, and an unknown kind each fail activation naming plugin, controller and field.
- TestPhase10AlbumRelations (fonoteka, assembled, cookie through /plytadmin): options for genre and artists, create and update with genre and ordered artists, show with labels, forged artist id 422.
- TestPhase10CollectionOwnerReadOnly: owner is readOnly in the form schema, owner options is 404, a submitted owner id does not change owner_id, show carries the owner label.
</behavior>
<action>Start with failing `TestPhase10AlbumRelations` and `TestPhase10RelationSave`, then implement D-17, D-18 and D-26 in `cabana/relation_field.go`, extending the existing model-owned relation style (framework code never guesses a table or key).
Contract: add `FieldRelationContract{Field string; Kind string ("belongsTo" or "belongsToMany"); NewRelated func() any; ForeignKey string (belongsTo column on the parent); NewPivot func() any; ParentForeignKey, RelatedForeignKey string (belongsToMany pivot columns); OrderColumn string (optional pivot column set to the array index); LabelColumn string (physical label column; default = the field's nameFrom mapped through pact.ListRelationColumnMapper when the controller implements it)}` and `FieldRelationProvider{ AdminFieldRelations() []FieldRelationContract }` on the controller. At activation, every `type: relation` field must have exactly one contract; kinds, related/pivot models and every named column are validated with `modelColumns`; any failure is a boot error naming plugin, controller and field. Compile `FormField.Multiple` (json `multiple`, belongsToMany) and `FormField.ReadOnly` (json `readOnly`, a belongsTo whose ForeignKey is in `protectedFillKey`, per D-26; the fill-key list and FormBeforeCreate stay as they are).
Options (D-17): add `pact.RelationExtendOptionsQuery` and mount `GET {apiBase}/{vendor}/{plugin}/{controller}/fields/{field}/options` with a `field` constraint of `[A-Za-z_][A-Za-z0-9_]*`, served through `protect`; answer 404 `not_found` for a field that is not a writable relation. Query the related model, apply the hook when the controller implements it, filter `search` with ILIKE on the label column using `escapeLike`, order by label then primary key, paginate with the Phase 9 relation limits (default 20, max 100, reuse the relation query normalization), and write `ListEnvelope[[]RelationOption]` where `RelationOption{Value uint json:"value"; Label string json:"label"}`.
Save (D-18): before scalar projection, lift the keys of writable relation fields out of the body (other nested values are still dropped); only keys present in the body are applied. Inside the existing save transaction, after `formBeforeCreate`/`formBeforeUpdate`: normalize ids with `normalizeIDs`, reject duplicates, and re-run the same scoped options query with `WHERE <primary key> IN (...)`; any id it does not return is a 422 `validation_failed` on that field (the transaction rolls back everything). A belongsTo value (or null for a nullable FK) is assigned to the parent's FK field before `tx.Create`/`tx.Save`; a belongsToMany value is written after the row exists: delete the parent's pivot rows, then bulk insert one pivot model per id in submitted order with OrderColumn set to the index when declared (Phase 5 contract; never Association Replace). Then the After hooks run. Required relation fields keep Phase 9 decision 304 (schema hint only), matching the PHP Album rules. `Show`, `Create` and `Update` return the record with relation values in `data` (belongsTo id or null; belongsToMany ids in pivot order, then primary key) and `meta.labels` as field to `[]RelationOption` (read-only fields included); document them as `RecordEnvelope{Data AdminRecord; Meta RecordMeta}`.
fonoteka: albums controller declares genre (belongsTo, models.Genre, ForeignKey genre_id, LabelColumn name) and artists (belongsToMany, models.Artist via models.AlbumArtist, ParentForeignKey album_id, RelatedForeignKey artist_id, OrderColumn sort_order, LabelColumn name); collections controller declares owner (belongsTo, the user model, ForeignKey owner_id, LabelColumn email). fonoteka implements no RelationExtendOptionsQuery (genres and artists have no collection column); say so in a comment. Adjust Phase 9 album and collection tests only where they pin the old relation-field JSON or the absence of relation keys; keep every collection_id/user_id/owner assertion.
Add the two routes to the admin route inventory, annotate them in `cabana/admin_openapi.go` (the research's `AdminFieldOptions` example; show/create/update as `RecordEnvelope`), and run `scripts/check-admin-openapi.sh` to regenerate the committed document and types.
Regeneration step (end of task): after the last annotation or documented-type edit, run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with this task's cabana changes, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task ends drift-clean.</action>
<verify>
<automated>go test ./cabana -run '^TestPhase10Relation(Options|Save|ForgedID|Boot)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase10AlbumRelations|TestPhase10CollectionOwnerReadOnly|TestAlbumsAdmin.*|TestCollectionsAdmin.*)$' -count=1) &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<fails_when>Any command exits non-zero; the cabana output lacks a "--- PASS" line for each of the four TestPhase10Relation tests or shows "no tests to run" or SKIP; the fonoteka run prints FAIL or "no tests to run"; check-admin-openapi.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- All six behaviors above pass against real PostgreSQL; Phase 9 album and collection suites still pass.
- `grep -c 'fields/{field}/options' cabana/http.go` prints 1 and `grep -c 'Association(' cabana/relation_field.go` prints 0.
- `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));assert '/{vendor}/{plugin}/{controller}/fields/{field}/options' in d['paths']"` exits 0.
- `grep -rniE 'golem15|album' cabana/relation_field.go` prints nothing.
</acceptance_criteria>
<done>An admin can fetch genre and artist choices, save an album's genre and ordered artists, and read them back with labels; forged or out-of-scope ids are rejected and the Collections owner cannot be reassigned.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Controllers speak their own copy through backend strings, messages and the declarative toolbar</name>
<files>pact/capabilities.go, phrasebook/lang.go, phrasebook/loader.go, phrasebook/translator.go, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, phrasebook/phase10_test.go, cabana/messages.go, cabana/lang.go, cabana/list_schema.go, cabana/list_schema_test.go, cabana/form_schema.go, cabana/relation.go, cabana/relation_test.go, cabana/http.go, cabana/admin_openapi.go, cabana/messages_test.go, cabana/security_coverage_test.go, internal/build/stubs/artifacts.tmpl, admin/openapi/admin.json, admin/src/api/schema.d.ts, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/artists/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/styles/config_form.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go</files>
<read_first>phrasebook/loader.go, phrasebook/translator.go, phrasebook/lang.go, cabana/list_schema.go, cabana/form_schema.go, cabana/relation.go, cabana/schema.go, cabana/http.go, internal/build/stubs/artifacts.tmpl, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/pl/lang.php, /media/nvme/dev/golem15/fonoteka/modules/backend/lang/en/lang.php, .planning/phases/10-admin-vue-spa/design/README.md (all copy), .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 6, Pitfalls 4 and 5), ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml</read_first>
<behavior>
- TestPhase10Forms: plain text maps to {"other": text}; a YAML plural map copies all CLDR forms; category-only pipes map by category; an exact or range pipe is not convertible (ok false).
- TestPhase10LangOverride: an override FS replaces an existing backend key, adds a key, and adds a new locale; a malformed override path fails activation; a backend key that cannot convert to forms (exact or range pipe) fails activation naming the key.
- TestPhase10Bundle: GET {prefix}/api/v1/lang without auth returns only backend::lang keys for the Accept-Language locale with fallback to app.fallback_locale, meta.locale set, Cache-Control no-cache; no plugin or lagoon key appears.
- TestPhase10Messages: omitted keys are filled with framework defaults; a plugin key resolves in pl and en; plural keys arrive as all forms; an unknown messages key fails at boot through DisallowUnknownField; a messages value naming a missing phrase key fails at boot when a translator is available.
- TestPhase10Toolbar: [create, delete] compiles in order; buttons: list_toolbar fails with a message containing "toolbar.buttons must be a list"; delete without showCheckboxes, duplicates and unknown actions fail; create is omitted from the served list when the controller has no compiled form.
- TestPhase10ControllerCopy (fonoteka, assembled): each of the five list schemas serves toolbarButtons ["create","delete"] and a complete Polish messages object; the album form serves create, update and saved; the editors relation schema serves link and linked with Polish plural forms.
- TestPhase10SPAKeysResolve: every backend::lang key literal found in admin/src resolves in both pl and en.
</behavior>
<action>(1) Backend strings, per D-20: add `phrasebook/backend/lang/{en,pl}/lang.yaml` embedded by `phrasebook/lang.go` and loaded in `Activate` as namespace `backend` right after `lagoon`, so keys resolve as `backend::lang.<group>.<key>`. Keep Winter key names where Winter has them (list.search_prompt, list.no_records, list.delete_selected, list.loading, list.prev_page, list.next_page, list.records_per_page, list.column_switch_true, list.column_switch_false, form.save, form.save_and_close, form.cancel, form.delete, form.none, form.update, form.create, form.return_to_list, form.close, relation.add, relation.link, relation.unlink, relation.remove) and add the SPA's own keys under auth.*, nav.*, list.*, form.*, relation.*, messages.* for every string in the design README (login copy, navigation aria labels, pagination range `:from–:to z :total`, results count, empty and empty-search states, selection pill, 422 banner, unsupported field `Nieobsługiwany typ pola: :type`, toast close, modal copy, Dodaj (:count)). Polish values follow the design README copy (D-06); English values are plain equivalents. Every plural text is a YAML CLDR map (one, few, many, other for pl; one, other for en), never a pipe string. Placeholders use `:count`, `:name`, `:term` per D-24. Framework message defaults per D-13: list create "Nowy rekord", deleteConfirm "Usunąć zaznaczone (:count)?", form saved "Zapisano", plus defaults for every other vocabulary key.
(2) Override layer and bundle: add `pact.HasLangOverrides` and `(*Catalog).Override(owner string, fsys fs.FS) error` reading `lang/<locale>/<namespace>/<group>.yaml`; it runs after every namespace is loaded, may replace existing keys and add locales, and fails on malformed paths. After overrides, `Activate` fails when any `backend::` key cannot convert to forms. Add `(*Translator).Forms(locale, key string) (map[string]string, bool)` (text → other; plural map → copy; category-only pipes → by category; exact/range pipes → false) and `(*Translator).Bundle(locale, prefix string) map[string]map[string]string` merging fallback-locale keys under requested-locale keys. Add `cabana/lang.go` and mount public `GET {apiBase}/lang` next to the auth routes (no guard; GET needs no CSRF header): data is the bundle for `backend::lang.`, meta.locale is the resolved locale, header `Cache-Control: no-cache`; document it as `Envelope[map[string]map[string]string]`.
(3) Messages, per D-13/D-24: `cabana/messages.go` defines fixed structs `listMessages{RecordCount, Create, SearchPrompt, Empty, EmptySearch, EmptySearchHint, Selected, DeleteSelected, DeleteConfirm, Deleted}`, `formMessages{Create, Update, Saved, DeleteConfirm, Deleted}` and `relationMessages{Link, LinkHint, CandidateSearch, Linked, UnlinkSelected, UnlinkConfirm, Unlinked, Empty}` with lowerCamel yaml tags, decoded strictly so an unknown key fails at boot. `config_list.yaml` and `config_form.yaml` gain `messages:`; `config_relation.yaml` gains `messages:` per relation (applying the D-13 rules to relation copy; Claude's discretion on vocabulary). Omitted keys take the framework default key; the list `searchPrompt` default is the existing `toolbar.search.prompt` when set. At activation, when a translator is published, every message key must exist in the catalog. Cached schemas keep keys; each response localizes to `messages` as key → CLDR form map (via Forms). `FormView` also serves `redirects` with the raw Winter `create.redirect`, `create.redirectClose`, `update.redirect`, `update.redirectClose` strings (the SPA maps them in Plan 10-03).
(4) Toolbar, per D-14: `listToolbar.Buttons` becomes an ordered list type with a custom `UnmarshalYAML(ast.Node)` (the `fieldMap`/`scopeMap` pattern) that rejects a scalar with `toolbar.buttons must be a list of actions (create, delete); the Winter partial "list_toolbar" is not supported`; accepted actions are exactly `create` and `delete`; duplicates, unknown actions and `delete` without `showCheckboxes: true` fail at boot. The compiled `ToolbarButtons` keeps declared order and omits `create` when the controller has no compiled form. Update the inline YAML fixtures in `cabana/list_schema_test.go` and the scaffold stub in `internal/build/stubs/artifacts.tmpl` (config_list gains `toolbar.buttons: [create]` and a search prompt) to the list syntax.
(5) fonoteka: set `toolbar.buttons: [create, delete]` in the five `config_list.yaml` files (each has `showCheckboxes: true`; keep `toolbar.search.prompt`), add `messages` blocks to the five list and form configs and to the editors relation, and add the referenced keys to `lang/{pl,en}/lang.yaml` under each existing group (item, artist, collection, genre, style) with natural Polish plurals (for example albums recordCount ":count pozycja w katalogu" / ":count pozycje w katalogu" / ":count pozycji w katalogu"; create "Nowy album"; searchPrompt "Szukaj albumów…"; editors link "Dodaj edytora" and linked "Dodano :count edytora" / "Dodano :count edytorów", matching the plugin's existing "Edytorzy" wording). Extend `TestPhase10LangCatalog` to also resolve every `backend::lang` key referenced by fonoteka YAML.
(6) Write the tests in `<behavior>` (`phrasebook/phase10_test.go` including TestPhase10SPAKeysResolve walking `../admin/src`, `cabana/messages_test.go`, fonoteka `admin_phase10_copy_test.go`), add `/lang` to the route inventory as public, and regenerate the admin document and types with `scripts/check-admin-openapi.sh`.
Regeneration step (end of task): the new `/lang` annotation and the `messages`, `toolbarButtons` and `redirects` fields on `ListSchema`, `FormView` and `RelationSchema` change the document, so after the last such edit run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with this task's cabana and phrasebook changes, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task ends drift-clean.</action>
<verify>
<automated>go test ./phrasebook ./cabana -run '^TestPhase10(Forms|LangOverride|SPAKeysResolve|Bundle|Messages|Toolbar)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; go test ./internal/build -run '^Test.*AdminController' -count=1 &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^(TestPhase10ControllerCopy|TestPhase10LangCatalog)$' -count=1 -v) &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<fails_when>Any command exits non-zero; output shows "no tests to run" or SKIP for a named Phase 10 test, or lacks a "--- PASS" line for each of TestPhase10Forms, TestPhase10LangOverride, TestPhase10SPAKeysResolve, TestPhase10Bundle, TestPhase10Messages, TestPhase10Toolbar, TestPhase10ControllerCopy and TestPhase10LangCatalog; check-admin-openapi.sh prints a diff.</fails_when>
</verify>
<acceptance_criteria>
- `grep -rn 'list_toolbar' ../fonoteka.go/plugins/golem15/fonoteka/controllers internal/build/stubs` prints nothing and `grep -c 'buttons: \[create, delete\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/*/config_list.yaml` prints 1 for each of the five files.
<!-- planner-discipline-allow: list_toolbar -->
- Activation converts every `backend::` key to CLDR forms (a pipe-plural backend string fails boot), and TestPhase10LangOverride proves an unconvertible backend key fails activation naming the key.
- Every named behavior test passes; the bundle response contains no key outside `backend::lang.`.
</acceptance_criteria>
<done>Each controller's list, form and relation schema arrives with complete, locale-resolved copy and a declarative toolbar, and the SPA can load every framework string from one public bundle.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Filters get their choices and the whole admin API is typed and proven against the wire</name>
<files>pact/capabilities.go, cabana/filter_schema.go, cabana/http.go, cabana/admin_openapi.go, cabana/filter_options_test.go, cabana/openapi_conformance_test.go, cabana/list_schema_test.go, cabana/security_coverage_test.go, cabana/phase09_contract_test.go, internal/tools/swagger2openapi/main.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/views/ListView.vue, admin/src/state/useAuth.ts, admin/src/state/useNavigation.ts, boardwalk/dist/**, ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go</files>
<read_first>cabana/filter_schema.go, cabana/query.go, cabana/admin_openapi.go, cabana/schema_types.go, cabana/settings.go, cabana/navigation.go, cabana/relation.go, internal/tools/swagger2openapi/main.go, cabana/testdata/list/all_filters.yaml, cabana/list_schema_test.go, admin/src/api/types.ts, admin/src/views/ListView.vue, .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Pattern 4, Pitfalls 7 and 8)</read_first>
<behavior>
- TestPhase10FilterOptions: a scope filter serves the model's FilterOptions(scope) as [{value, label}] with labels localized; an undeclared scope name is 404; a principal without the controller permission is 403 before the provider runs; a scope filter whose model lacks FilterOptions fails activation.
- TestPhase10OpenAPIConformance: for every route in the admin inventory, the real handler response (httptest against a fixture registry) decodes into the Go type its annotation documents with DisallowUnknownFields, and every documented path exists in admin/openapi/admin.json with that schema reference.
- TestPhase10Controllers (fonoteka, assembled, cookie through /plytadmin): for Albums, Artists, Collections, Genres and Styles the list schema columns equal the tracked columns.yaml keys in order, the form schema fields equal the tracked fields.yaml keys in order, every served field type is one of the eight D-05 built-ins (text, textarea, number, checkbox, switch, dropdown, relation, relation-manager) so no real screen falls back to the unsupported box, the list returns data and meta, and one created record is readable with the same keys.
</behavior>
<action>(1) Filter choices, per D-27: add `pact.FilterOptions{ FilterOptions(scope string) []Option }` implemented by the model (the same model that implements `pact.FilterScope`). At activation a `type: scope` filter whose model lacks it fails with an error naming the scope and D-27; update the acme fixture model used by `cabana/list_schema_test.go` to implement it. Mount `GET {apiBase}/{vendor}/{plugin}/{controller}/filters/{scope}/options` through `protect` with a `scope` identifier constraint; 404 for a name that is not a declared scope filter of that controller's list; data is `[]FilterOption{Value string; Label string}` with labels passed through the translator; meta.locale.
(2) Full typing, per D-15/D-16: give every remaining admin route a concrete response type in `cabana/admin_openapi.go`: settings list `Envelope[[]SettingsEntry]`, settings schema and form schema `Envelope[FormView]`, settings GET/PUT `Envelope[SettingsResult]`, relation schema `Envelope[RelationSchema]`, relation linked/candidates `ListEnvelope[[]AdminRecord]`, link/unlink `Envelope[RelationMutationResult]`, bulk delete and delete `Envelope[BulkResult]`, logout `Envelope[AdminLogoutData]`, filter options `Envelope[[]FilterOption]`; every protected route documents 401, 403 and 404, write routes document 422, CSRF-protected routes document 403. Add `--requiredByDefault`-friendly `omitempty` only where a field is genuinely optional. In `internal/tools/swagger2openapi/main.go` rewrite the component for `cabana.jsonScalar` to a nullable oneOf of string, number and boolean, and `cabana.fieldContext` to a oneOf of string and array of string. Remove the untyped `SuccessEnvelope` from annotations once nothing references it. Regenerate with `scripts/check-admin-openapi.sh`.
(3) Conformance: `cabana/openapi_conformance_test.go` builds a fixture registry (acme names only), calls every inventoried handler with httptest (using the existing Testcontainers PostgreSQL helper where rows are needed), decodes each body into the documented Go envelope type with `DisallowUnknownFields`, and cross-checks the path and schema reference in `admin/openapi/admin.json`, so the document cannot drift from the wire. Keep `TestPhase09PermissionMatrix` and `TestPhase09ContractInventory` green with the new routes (options, filters, lang).
(4) SPA stays green: update `admin/src/api/types.ts` aliases and any call site in `ListView.vue`, `useAuth.ts` and `useNavigation.ts` whose types changed so `npm --prefix admin run typecheck` passes with no casts to `any`; rebuild `boardwalk/dist` only if the built output changed.
(5) fonoteka `admin_phase10_controllers_test.go` implements TestPhase10Controllers (D-08: fields and columns are exactly the tracked YAML; no mock-only fields).
Regeneration step (end of task): steps (1) and (2) change the document and the converter output, so after the last annotation, documented-type or converter edit run `scripts/check-admin-openapi.sh` once more, commit the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` together with the step (4) SPA updates that consume them, and confirm `scripts/check-admin-openapi.sh --check` prints no diff; the task (and the plan) ends drift-clean.</action>
<verify>
<automated>go test ./cabana -run '^TestPhase10(FilterOptions|OpenAPIConformance)$|^TestPhase09(PermissionMatrix|ContractInventory)$' -count=1 -v &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v) &amp;&amp; go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated>
<fails_when>Any command exits non-zero; a named test is missing its "--- PASS" line or shows "no tests to run" or SKIP; either drift script prints a diff; vue-tsc reports an error.</fails_when>
</verify>
<acceptance_criteria>
- `python3 -c "import json;d=json.load(open('admin/openapi/admin.json'));s=json.dumps(d);assert 'cabana.SuccessEnvelope' not in s and '/{vendor}/{plugin}/{controller}/filters/{scope}/options' in d['paths'] and '/lang' in d['paths']"` exits 0.
- `grep -rn 'as any' admin/src` prints nothing.
- TestPhase10OpenAPIConformance covers every route in the admin inventory (the test fails when a route has no conformance case).
- Both repositories pass `go vet ./...` and `go test ./...`.
</acceptance_criteria>
<done>Filter bars can fetch model-backed choices, and every admin endpoint the SPA calls has a generated TypeScript type that is proven to match what the handler writes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| SPA → options/filters endpoints | Untrusted search, paging and field/scope names select related rows |
| SPA → record save | Untrusted relation ids and keys become foreign keys and pivot rows |
| Anonymous browser → /lang | Unauthenticated callers read the string bundle |
| Plugin YAML/override FS → boot | Plugin-supplied copy and toolbar declarations shape every schema |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-09 | Elevation of Privilege | cabana relation save (IDOR via relation ids) | high | mitigate | Submitted ids are revalidated through the same RelationExtendOptionsQuery-scoped query inside the save transaction; unknown, out-of-scope and duplicate ids are 422 and roll back; TestPhase10RelationForgedID and TestPhase10AlbumRelations. |
| T-10-10 | Tampering | belongs-to mapping of protected foreign keys (mass assignment) | high | mitigate | D-26: a belongsTo whose FK is a protected fill key is readOnly, never written, and its options endpoint is 404; protectedFillKey and FormBeforeCreate unchanged; TestPhase10CollectionOwnerReadOnly. |
| T-10-11 | Information Disclosure | fields/{field}/options enumeration | medium | mitigate | Served through protect (controller permission before SQL), scoped by the hook, per_page capped at 100, 404 for non-relation and read-only fields (no user-email enumeration via owner); TestPhase10RelationOptions. |
| T-10-12 | Information Disclosure | public /lang bundle | low | mitigate | Bundle limited to the backend::lang prefix; TestPhase10Bundle asserts no other namespace appears. |
| T-10-13 | Tampering | messages and toolbar YAML | low | mitigate | Strict decoding with unknown-key rejection, custom toolbar unmarshal, boot-time key existence checks; TestPhase10Messages and TestPhase10Toolbar. |
| T-10-14 | Tampering | OpenAPI document versus handler output | medium | mitigate | TestPhase10OpenAPIConformance decodes every handler response into its documented type with unknown fields disallowed; check-admin-openapi.sh --check guards drift. |
| T-10-15 | Elevation of Privilege | filters/{scope}/options | medium | mitigate | protect() before the provider, scope names allow-listed against the compiled list filters, 404 otherwise; TestPhase10FilterOptions. |
| T-10-SC | Tampering | npm/Go dependencies | high | mitigate | No new npm or Go package; admin/ uses npm ci against the lockfile approved in Plan 10-01; swag stays pinned at v1.16.6 via go run. |
</threat_model>
<verification>
Run `go vet ./... && go test ./...` in summercms.go and `(cd ../fonoteka.go && go vet ./... && go test ./...)`, then `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` and `npm --prefix admin run typecheck`. A non-zero exit, a skipped PostgreSQL Phase 10 test, or a printed diff fails the plan.
</verification>
<success_criteria>
- Relation choices, relation saves with labels, the read-only owner, messages, the toolbar, the string bundle and filter choices behave as specified and are covered by named tests.
- The admin OpenAPI document is fully typed and proven against the wire; the SPA still typechecks against it.
- The five fonoteka controllers serve exactly their tracked YAML through the prefix with complete Polish copy.
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md` when done.
</output>