Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-01-PLAN.md
2026-10-04 19:41:08 +02:00

70 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12.1-user-plugin-admin-screens 01 execute 1
modules/pact/capabilities.go
modules/pact/capabilities_test.go
modules/pact/README.md
modules/cabana/contracts.go
modules/cabana/actions.go
modules/cabana/crud.go
modules/cabana/extension.go
modules/cabana/registry.go
modules/cabana/list_schema.go
modules/cabana/form_schema.go
modules/cabana/schema_types.go
modules/cabana/messages.go
modules/cabana/query.go
modules/cabana/http.go
modules/cabana/relation.go
modules/cabana/relation_child.go
modules/cabana/relation_field.go
modules/cabana/admin_openapi.go
modules/cabana/security_coverage_test.go
modules/cabana/phase121_fixture_test.go
modules/cabana/phase121_actions_test.go
modules/cabana/example_actions_test.go
modules/cabana/testdata/roster
modules/cabana/README.md
modules/phrasebook/backend/lang/en/lang.yaml
modules/phrasebook/backend/lang/pl/lang.yaml
admin/openapi/admin.json
admin/src/api/schema.d.ts
admin/src/api/types.ts
admin/src/components/list/BulkActionsMenu.vue
admin/src/components/list/RowStateBadges.vue
admin/src/components/list/ListToolbar.vue
admin/src/components/list/DataTable.vue
admin/src/components/form/RecordActions.vue
admin/src/components/form/FormErrorBanner.vue
admin/src/views/ListView.vue
admin/src/views/FormView.vue
admin/tests/smoke/actions.smoke.test.ts
admin/tests/fixtures/roster.list-schema.json
admin/tests/fixtures/roster.list.json
admin/tests/fixtures/roster.record.json
modules/boardwalk/dist
docs/backend/admin-controllers.md
docs/backend/lists-and-filters.md
docs/backend/forms.md
docs/backend/users-and-permissions.md
docs/backend/admin-spa.md
true
SC-1
SC-3
tokens raw_tokens tasks confidence
260000 260000 4 low
truths artifacts key_links
Per D-26, this is plan 01 of five and covers the framework actions only (declared bulk actions, record actions, row state and the 403 error type) in summercms.go; plans 02 to 05 follow in order and plan 02 tags v0.1.3.
Per D-09, a controller registers named bulk actions through pact.HasAdminBulkActions, config_list.yaml offers them with `bulkActions:` (which needs showCheckboxes: true), and POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/bulk/{action} resolves the posted ids through the controller's ListExtendQuery scope with row locks inside one transaction before the action runs: zero matching rows is a no-op answer with affected 0, a partial match is 409 and rolls back, and the action receives loaded records, never ids.
Per D-09, a bulkActions name the controller does not register, a duplicate name, a reserved name (create, delete), an action without Run or without a label, or bulkActions without showCheckboxes: true stops boot with an error naming plugin, controller and file; bulk delete behaves exactly as before.
Per D-10, a controller registers record actions through pact.HasAdminRecordActions, config_form.yaml offers them with `recordActions:`, and POST .../{controller}/{id}/actions/{action} loads the record through FormExtendQuery with a row lock in one transaction, answers 404 for a missing or out-of-scope id, 409 when the action's Applies reports false, and otherwise runs the action; the show response lists in meta.actions only the declared actions the admin may run and that apply to the record.
Bulk and record actions each check their own Permissions on top of the controller's RequiredPermissions (403 and an auth log line on denial), are left out of the list schema and of meta.actions for an admin who may not run them, use requireAjax, sit under the backend guard, and an undeclared or unregistered action name answers 404.
Per D-12, a controller implementing pact.ListRowStates is called once per list page with that page's records and the list's database handle; the response carries meta.row_states keyed by row id with values from the fixed set deleted, negative, disabled in that order; a value outside the set is dropped and logged, never sent; a controller without the hook sends no row_states key.
Per D-13 (framework side), a soft-deleted record that a controller's ListExtendQuery and FormExtendQuery include (Winter's withTrashed) can be shown, updated, targeted by bulk and record actions and permanently deleted by the controller's FormAfterDelete through the admin API; controllers that do not include soft-deleted rows behave as before.
Per D-27 (G4, error half), a hook, bulk action, record action, toolbar action or widget action that returns *cabana.ForbiddenError is answered 403 with code forbidden, the error's localized Message and its Details, and the surrounding transaction is rolled back; every other non-validation error stays the opaque 500.
Per D-25, the change ships with modules/pact/README.md, modules/cabana/README.md, the docs/backend pages, admin/openapi/admin.json, admin/src/api/schema.d.ts and a rebuilt modules/boardwalk/dist in the same commits; the fixture uses the neutral plugin id acme.roster and no file names a consuming application; `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` pass.
The two new routes are in the route inventory, the permission matrix and the OpenAPI document: TestPhase09ContractInventory, TestPhase09PermissionMatrix and TestPhase10OpenAPIConformance pass.
No Go module and no npm package is added or changed in version by this plan.
UI S1 empty: With zero permitted bulk actions the bulk menu is not rendered; with nothing selected the trigger is disabled and keeps its label.
UI S1 loading: While a bulk action is in flight the confirm dialog stays open with both buttons disabled and a spinner on the confirm button until the POST settles, and the menu trigger is disabled.
UI S1 error: A bulk action failure shows a danger toast: 409 uses `list.bulk_stale`, clears the selection and reloads the list; 403 uses the server message or `list.action_forbidden` and keeps the selection; any other status uses the server message or `extension.action_failed`, keeps the selection and reloads nothing.
UI S1 populated: Bulk menu items render in declared order at 14/400 with a 40px minimum height and no icons, groups or separators.
UI S1 partial: A bulk action succeeds or rolls back as a whole; the success toast shows the server's affected count, which may be lower than the selection when the plugin skips rows.
UI S1 overflow: Many bulk actions extend the menu downward inside the viewport (Reka collision handling) and the toolbar cluster wraps.
UI S1 zero-one-many: The default bulk confirm and the `list.bulk_done` toast use `:count` with CLDR plural forms; one selected row and many share one flow.
UI S1 long-text: A long bulk action label wraps inside the 240 to 320px menu and is never truncated; the confirm message wraps inside the dialog.
UI S2 loading: While a record action runs the confirm dialog is busy and every footer button is disabled.
UI S2 error: A record action failure: 409 shows the `form.action_stale` toast and reloads the record and hint; 404 shows the form load-failure alert; 403 and other statuses show a danger toast with the server message or the framework fallback.
UI S4 populated: Each row state renders a text badge in the first cell plus the row text style from the state table; the row background is never changed.
UI S4 partial: A row may carry any subset of the states; each renders independently and their text styles combine.
UI S4 overflow: In a first cell with states the text truncates (`min-w-0 truncate`) and badges never shrink; the table keeps its horizontal scroll.
UI S4 zero-one-many: A row with no state is unchanged; one or several states render badges in the fixed order deleted, negative, disabled.
UI S4 long-text: Row state badges are `whitespace-nowrap` and never struck through; a long first-cell value truncates before the badges.
UI S6b empty: A 403 on save with no server message shows the banner with `form.forbidden`.
UI S6b error: A 403 on create or update saves nothing, keeps every entered value and the dirty state, and shows a persistent role=alert banner (not a toast) that clears on the next save attempt.
UI S6b partial: When the 403 `error.details` names fields, each is marked through its error line and the first is focused; without details only the banner shows.
UI S6b overflow: A long forbidden message wraps inside the banner and is never truncated.
UI S6b long-text: A long forbidden message wraps inside the banner and is never truncated.
statement verification
Focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel backstop
statement verification
A row state outside the fixed set renders no badge and no class, and each known state renders its text badge backstop
path provides contains
modules/pact/capabilities.go AdminBulkAction, HasAdminBulkActions, AdminRecordAction, HasAdminRecordActions, RowState, ListRowStates HasAdminBulkActions
path provides contains
modules/cabana/actions.go bulkAction and recordAction handlers, ForbiddenError mapping in runAction bulkActionOf
path provides contains
modules/cabana/crud.go CRUDService.BulkAction, CRUDService.RecordAction, ForbiddenError, BulkActionResult ForbiddenError
path provides
modules/cabana/testdata/roster neutral acme.roster fixture tree (controllers/people, models/person, lang)
path provides
modules/cabana/phase121_actions_test.go TestBulkAction*, TestListSchemaBulkActions*, TestRecordAction*, TestRowState*, TestForbidden* smoke tests
path provides
admin/src/components/list/BulkActionsMenu.vue bulk actions menu per UI-SPEC S1
path provides
admin/src/components/list/RowStateBadges.vue row state text badges per UI-SPEC S4
path provides
admin/src/components/form/RecordActions.vue record action buttons with confirm and request flow per UI-SPEC S2 (mounted by plan 02 in the preview footer)
path provides
modules/boardwalk/dist rebuilt embedded SPA
from to via pattern
modules/cabana/http.go modules/cabana/actions.go POST /{vendor}/{plugin}/{controller}/bulk/{action} and POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}, both wrapped in requireAjax requireAjax(s.(bulkAction|recordAction))
from to via pattern
modules/cabana/crud.go modules/pact/capabilities.go BulkAction calls lockScoped and hands loaded records to AdminBulkAction.Run lockScoped
from to via pattern
admin/src/views/ListView.vue admin/src/components/list/BulkActionsMenu.vue ListToolbar renders the menu from schema.bulkActions and ListView.onBulkAction posts the selected ids onBulkAction
from to via pattern
modules/cabana/crud.go modules/cabana/actions.go writeCRUDError, lifecycleFailure and runAction each classify *ForbiddenError ForbiddenError

Phase Goal

ROADMAP Phase 12.1 goal (verbatim; the line is not written in As a / I want to / so that form and no story was invented for it): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.

This plan's slice: after it, any plugin can offer permission-gated bulk actions and record actions, mark list rows with a state, and refuse a write with a 403 that the admin can read. The user screens (plans 03 and 04) are built from these parts.

Add four generic admin features to the framework (`modules/pact`, `modules/cabana`, the admin SPA): declared bulk actions (D-09), record actions (D-10), row state (D-12) and `cabana.ForbiddenError` mapped to 403 (D-27, the error half of G4). Each lands end to end: Go contract, route, per-principal schema, OpenAPI document, generated TS types, SPA component, phrase keys, neutral fixture test, module READMEs, docs pages and the rebuilt `dist/`.

Purpose: success criteria 1 and 3 depend on these features; they must exist and be tagged (plan 02) before the plugin screens are written. Output: the contracts and routes listed under "Artifacts this phase produces", the acme.roster fixture, smoke tests, updated READMEs and docs.

Repo: summercms.go only. Every commit keeps go vet ./... and go test ./... green and carries its own README, docs, OpenAPI, TS types and modules/boardwalk/dist changes, so scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh stay clean at every commit. Code and planning docs go in separate commits. Never add co-author tags. Fixtures, READMEs and docs use neutral names (acme, blog) and never name a consuming application.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md @.planning/phases/10-admin-vue-spa/design/README.md @modules/cabana/actions.go @modules/cabana/crud.go - `modules/pact/capabilities.go`: `AdminAction{Name, Label, Permissions, Run}`, `AdminActionInput`, `AdminActionResult{Message, Fill}`, `HasAdminActions`, `ListExtendQuery`, `FormExtendQuery`, the Form and Relation hooks, `AdminPartialData`. pact already imports gorm. - `modules/cabana/contracts.go`: `CompiledController{PluginID, Controller, List, Form, Relations, Writable, FieldRelations, Actions map[string]pact.AdminAction, ...}`, `Allows(principal, required)`, `requiredOf(ctl)`, `WriteData`, `WriteError`, `WriteErrorDetails`. - `modules/cabana/actions.go`: `toolbarAction`, `toolbarActionOf`, `allowAction` (403 with `msgForbidden` and `logAuth`), `runAction`, `decodeActionRequest` (strict: UseNumber, DisallowUnknownFields, no trailing tokens), `readScopedRecord`. - `modules/cabana/crud.go`: `BulkDelete` (normalizeIDs, lagoon.Transaction, withTx, lockScoped, partialSelection), `loadRecord` (FormExtendQuery, FOR UPDATE, recordNotFound), `writeCRUDError` (413, 422, 404, 409, else 500), `lifecycleFailure`, `ValidationError{Details}`, `BulkDeleteInput{IDs []any}`, `BulkResult{Deleted}`, `ShowRecord`, `projectFullRecord`. - `modules/cabana/extension.go`: `builtinToolbarActions` (create, delete), `compileActions`, `compileExtension`. `modules/cabana/registry.go`: `compileContributions` validates action permissions with `validatePermissions`. - `modules/cabana/list_schema.go`: `listDocument` (strict decode), `toolbarButtons.UnmarshalYAML`, `compileToolbarButtons`, the `bulk` slice with the built-in delete entry, `localizeBulkActions`. `modules/cabana/schema_types.go`: `BulkAction{Name, Label}`, `ListSchema.BulkActions`, `ToolbarAction`. - `modules/cabana/query.go`: `ExecuteList` (runs on the pool, not in a transaction), `ListMeta{Page, PerPage, Total, LastPage}`, `ListResult`. `modules/cabana/relation_field.go`: `RecordMeta{Labels}`, `RecordEnvelope`, `RecordResult`. - `modules/cabana/messages.go`: `listMessageKeys` / `ListMessages` / `listMessageDefaults` (yaml key, JSON key, default phrase key per message). - `modules/cabana/http.go`: `service.mount` (routes inside `r.GroupRaw(api, []string{"backend"}, ...)`, writes wrapped in `requireAjax`), `listSchema` (per-principal filter of ToolbarActions into a new slice), `show`, `list`, `protect`, `decodeCappedBulk`. - `modules/cabana/admin_openapi.go`: stub functions with swag comments; `AdminIDsRequest{IDs []uint64}`, `Envelope[T]`, `ListEnvelope[T]`, `AdminActionRequest`, `AdminActionResult{Message, Fill}`. `modules/cabana/security_coverage_test.go`: `phase09Routes` (one row per mounted route). - Test harness precedent: `modules/cabana/phase101_actions_test.go` (`actSpy`, `actPlugin`, `actController` with a tenant scope, `actEnv.call` with the auth modes bearer, limited, cookie, cookie-only, `newActEnv`). - SPA: `admin/src/views/ListView.vue` (`onDelete`, `onAction`, `selected`, `partialReload`, `useConfirm`), `admin/src/components/list/ListToolbar.vue` (props `buttons`, `actions`, `busyAction`; emits `delete`, `action`), `admin/src/components/list/DataTable.vue` (props `columns`, `rows`, `rowLink`, `selectable`, `selected`), `admin/src/components/ui/confirm.ts` (`ask(request, run)` keeps the dialog open and busy while `run` settles), `admin/src/components/shell/UserMenu.vue` (DropdownMenu surface classes), `admin/src/views/FormView.vue` (`save`, `showErrors`, `onDelete`), `admin/src/components/form/FormErrorBanner.vue`, `admin/src/api/types.ts` (aliases onto `components['schemas']`), `admin/src/app/i18n.ts` (`t`, `tc`, `message`). - Generation: `scripts/check-admin-openapi.sh` (no argument regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`; `--check` fails on drift), `npm --prefix admin run build` (writes `modules/boardwalk/dist`), `scripts/check-admin-dist.sh`.

Artifacts this phase produces

(This plan's share. The names below are fixed for plans 02 to 05.)

  • pact types: AdminBulkAction{Name, Label, Confirm string; Permissions []string; Run}, AdminBulkActionInput{Records []any}, AdminBulkActionResult{Message string; Affected int}, HasAdminBulkActions{AdminBulkActions() []AdminBulkAction}; AdminRecordAction{Name, Label, Confirm string; Permissions []string; Applies; Run}, AdminRecordActionInput{RecordID uint64; Record any}, AdminRecordActionResult{Message string}, HasAdminRecordActions{AdminRecordActions() []AdminRecordAction}; RowState (string) with constants RowStateDeleted ("deleted"), RowStateNegative ("negative"), RowStateDisabled ("disabled"); ListRowStates{ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error)}.
  • cabana types and functions: ForbiddenError{Message string; Details map[string]any}, BulkActionResult{Message, Affected}, RecordAction{Name, Label, Confirm}, CRUDService.BulkAction, CRUDService.RecordAction; fields CompiledController.BulkActions, CompiledController.RecordActions, BulkAction.Confirm, RecordMeta.Actions (json actions, omitempty), ListMeta.RowStates (json row_states, omitempty), ListMessages.RowStateDeleted, .RowStateNegative, .RowStateDisabled.
  • YAML keys: config_list.yaml bulkActions: (list of names) and messages.rowStateDeleted, messages.rowStateNegative, messages.rowStateDisabled; config_form.yaml recordActions: (list of names).
  • Routes: POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/bulk/{action} (body AdminIDsRequest, result Envelope[BulkActionResult]); POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/actions/{action} (body {}, result Envelope[AdminActionResult] with an empty fill).
  • OpenAPI stubs AdminBulkAction, AdminRecordAction in modules/cabana/admin_openapi.go; TS aliases BulkAction, BulkActionResult, RecordAction in admin/src/api/types.ts.
  • SPA components: BulkActionsMenu.vue, RowStateBadges.vue, RecordActions.vue; ListToolbar.vue props bulkActions, bulkBusy and event bulk; DataTable.vue props rowStates, stateLabels; ListView.vue onBulkAction; FormErrorBanner.vue prop forbidden; FormView.vue forbidden-save handling.
  • Phrase keys (en, pl): backend::lang.list.{bulk_actions, bulk_confirm, bulk_done, bulk_stale, action_forbidden}, backend::lang.form.{action_confirm, action_done, action_stale, forbidden}, backend::lang.messages.list.{row_state_deleted, row_state_negative, row_state_disabled}.
  • Fixture: modules/cabana/testdata/roster/ (plugin acme.roster, controller acme.roster.people), modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/example_actions_test.go; SPA fixtures admin/tests/fixtures/roster.*.json; admin/tests/smoke/actions.smoke.test.ts.

Planner decisions recorded for this plan

  • Action namespaces. Bulk actions and record actions each have their own name space next to the existing toolbar and widget actions: a name is unique within its kind, and create and delete stay reserved in every kind. This departs from the RESEARCH note "one shared namespace" because D-14 needs activate and unban both as a bulk action and as a record action; the routes differ per kind, so the names cannot be confused.
  • Row state transport. States travel in the list response's meta.row_states (a typed field of ListMeta), keyed by the row id as a decimal string, not inside the untyped row map: a column key can then never collide with it.
  • Offered record actions. Only the show route (GET .../{id}) fills meta.actions; create and update responses omit it (the SPA reads the record again when it opens the preview).
  • 403 text. A ForbiddenError with an empty Message is written with an empty message; the SPA then uses its own fallback key. The framework's generic permission denials keep their existing constant text.
  • Record actions in the SPA. RecordActions.vue is built and tested here and mounted in the preview footer by plan 02 (UI-SPEC S2: record actions render on the preview screen only). The boot rule "recordActions needs a preview" is added by plan 02 together with the preview: key.
  • Spec-less probe fallback: skipped. The phase has no requirement IDs and no SPEC.md, so no edge or prohibition probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" and the UI-SPEC "UI Considerations" rows, which are lifted into must_haves.
  • Assumption-delta and API-coverage detectors: both report no signal for this phase (no external API is integrated; no singular-to-plural identity change). The schema push gate does not apply (GORM with gormigrate, none of the listed ORMs).
Task 1: An admin selects rows in a list, picks a declared bulk action from the Bulk actions menu, confirms, and the selected records change in one scoped transaction D-09 (user-confirmed): list YAML and the pact action contract grow; every plugin's list config may come to depend on `bulkActions`, `AdminBulkAction` and the bulk route. modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/example_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/list/BulkActionsMenu.vue, admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.list-schema.json, admin/tests/fixtures/roster.list.json, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S1, Copywriting Contract, "Framework: all new keys"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (sections for capabilities.go, actions.go, crud.go, extension.go and list_schema.go, schema_types.go, http.go, admin_openapi.go, framework tests and fixture, ListToolbar/ListView), modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase101_actions_test.go, modules/cabana/bulk_test.go, modules/cabana/testdata/extension/controllers/gadgets/config_list.yaml, modules/cabana/example_controller_test.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/src/views/ListView.vue, admin/src/components/list/ListToolbar.vue, admin/src/components/shell/UserMenu.vue, admin/src/components/ui/confirm.ts, admin/src/components/ui/ConfirmDialog.vue, admin/src/api/types.ts, admin/tests/smoke/list.smoke.test.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/fixtures/extension.list-schema.json, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md, scripts/check-admin-openapi.sh Per D-09 and D-25; RESEARCH Pattern 1 and Pattern 3; UI-SPEC S1. Repo: summercms.go; one code commit that also carries the generated outputs, README and docs.

(1) pact (modules/pact/capabilities.go, directly after HasAdminActions): add AdminBulkAction with fields Name, Label, Confirm (strings), Permissions ([]string) and Run of type func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error), the Run field tagged json "-"; AdminBulkActionInput with the single field Records []any (pointers to the controller's model, loaded and row-locked through ListExtendQuery, ordered by primary key; never ids); AdminBulkActionResult with Message string and Affected int; interface HasAdminBulkActions with method AdminBulkActions() []AdminBulkAction. Doc comments follow AdminAction's style and state: the framework owns the route, the CSRF check, authentication, id scoping and the transaction; Run owns only business logic and reads the write transaction with cabana.TxFromContext; Name is an identifier unique among the controller's bulk actions, create and delete are reserved, a record action may reuse a bulk action's name; Label and Confirm are phrase keys or text; an empty Confirm means the framework's default confirm text; Permissions are checked on top of RequiredPermissions.

(2) Compile. contracts.go: CompiledController gains BulkActions map[string]pact.AdminBulkAction. extension.go: a compileBulkActions(ctl) with the four boot errors compileActions has (name not an identifier, reserved built-in name, duplicate, no Run), called from compileExtension and stored on the controller. registry.go compileContributions: validate each bulk action's Permissions with validatePermissions, owner text "bulk action ID.NAME". list_schema.go: listDocument gains a field for yaml key bulkActions, decoded like toolbarButtons (a sequence of names, duplicates refused, a scalar refused with the message "bulkActions must be a list of bulk action names the controller registers"); in compileList, a non-empty list without showCheckboxes: true is the boot error "bulkActions needs showCheckboxes: true", a name the controller does not register through pact.HasAdminBulkActions is "bulkActions: unsupported action NAME (want a bulk action the controller registers)", an empty Label is "bulkActions: action NAME needs a label"; all wrapped with bootErr. Declared entries are appended to the existing bulk slice after the built-in delete entry, in declared order, each carrying Name, Label and Confirm. schema_types.go: BulkAction gains Confirm (json key confirm, omitempty) and its doc comment says the entry is per-principal for declared actions; localizeBulkActions translates Confirm as well as Label.

(3) Service and route. crud.go: add BulkActionResult (Message json message, Affected json affected) and CRUDService.BulkAction(ctx, cc, action, in BulkDeleteInput), copying BulkDelete's shape: normalizeIDs, lagoon.Transaction, withTx, newWritableModel, lockScoped; zero rows returns Affected 0 without calling Run; a row count different from the id count returns partialSelection; otherwise exactly one call of action.Run with the loaded rows; a Run error goes through lifecycleFailure; Message is resolved with the service's translator in the request locale (translateKey). actions.go: add bulkActionOf(cc, name) (true only when the name is in the list's declared bulk actions, is not the built-in delete, and is registered in cc.BulkActions) and the handler bulkAction: protect, then bulkActionOf (404 not_found), then the action's own permissions (turn allowAction into a helper that takes the permission list so every action kind shares the 403 and the auth log line), then decodeCappedBulk, then the service call, writeCRUDError on error, WriteData 200 on success. After a successful run write one slog.Info line "cabana: admin bulk action" with the controller id, action name, admin id and affected count and no record contents. http.go mount: register POST /{vendor}/{plugin}/{controller}/bulk/{action} wrapped in requireAjax with constrainController and the same identifier constraint on action as the toolbar route. listSchema handler: after the toolbar filter, rebuild view.BulkActions into a new slice that keeps the built-in delete entry and each declared entry whose registered Permissions the principal passes (the cached schema is never mutated).

(4) Contract. admin_openapi.go: stub function AdminBulkAction modelled on AdminBulkDelete (summary "Run a declared bulk action", a description stating that ids are resolved through the list scope in one transaction and that a partial selection is 409, path parameter action, body AdminIDsRequest, success Envelope[BulkActionResult], failures 401, 403, 404, 409, 422, router /{vendor}/{plugin}/{controller}/bulk/{action} post). security_coverage_test.go: add the row for "POST /{vendor}/{plugin}/{controller}/bulk/{action}" to phase09Routes. Run scripts/check-admin-openapi.sh, then add aliases BulkAction and BulkActionResult to admin/src/api/types.ts.

(5) Fixture (neutral, reused by every later task of plans 01, 02 and 05). Tree modules/cabana/testdata/roster: controllers/people/config_list.yaml (modelClass matching the controller's ModelName, list pointing at models/person/columns.yaml, showCheckboxes: true, toolbar.buttons [create, delete], bulkActions [activate, archive], a messages block), controllers/people/config_form.yaml, models/person/columns.yaml (name, email), models/person/fields.yaml (name, email as type text), lang/en/lang.yaml and lang/pl/lang.yaml with the acme.roster::lang.* keys the YAML and actions name. modules/cabana/phase121_fixture_test.go: plugin rosterPlugin (ID acme.roster; permissions acme.roster.access and acme.roster.manage; AdminFS over the fixture directory; LangFS as actPlugin does), controller rosterController (ID acme.roster.people, ConfigDir controllers/people, RequiredPermissions acme.roster.access, NewRecord returning a rosterPerson with columns id, tenant, name, email, active, banned and a gorm.DeletedAt deleted_at, table roster_people; ListExtendQuery and FormExtendQuery scoping to tenant acme), a spy recording what each Run receives, bulk actions activate (permission acme.roster.manage; sets active true on rows that are not active through cabana.TxFromContext and returns the changed count as Affected) and archive (permission acme.roster.access), and newRosterEnv modelled on newActEnv with the four auth modes of actEnv.call.

(6) Smoke test modules/cabana/phase121_actions_test.go: TestBulkActionTracer (the list schema offers activate and archive to the full admin and omits activate for the limited admin; POST bulk/activate with two in-scope ids answers 200 with affected 2 and both rows are active; the spy saw two records and no id list; an id of another tenant mixed in answers 409 and nothing changed; an undeclared name answers 404; the limited admin gets 403; a cookie-only request without the Ajax header gets 403) and TestListSchemaBulkActionsBoot (an unregistered name, a duplicate, and bulkActions without showCheckboxes each fail CompileList with the messages above). modules/cabana/example_actions_test.go: a compiled example controller registering one bulk action, for the docs src= fence.

(7) SPA per UI-SPEC S1. New admin/src/components/list/BulkActionsMenu.vue: Reka DropdownMenuRoot (modal false), a trigger rendered as Button variant outline size md with the label key backend::lang.list.bulk_actions, a trailing ChevronDown 16px and data-action="bulk-actions"; content with the UserMenu surface classes at width 240 and maximum width 320, align end, side offset 8; one DropdownMenuItem per action in declared order with data-bulk-action set to the name, minimum height 40, text that wraps and is never truncated, no icons, groups or separators; props actions and disabled; event select with the action name. ListToolbar.vue: props bulkActions (default empty) and bulkBusy, event bulk; the menu sits directly after the selection pill and before the toolbar buttons, is rendered only when at least one action exists, and its trigger is disabled while nothing is selected or an action runs. ListView.vue: bulkActions computed from schema.bulkActions without the built-in delete; onBulkAction(name) copying onDelete: normalise ids, then confirm.ask with the action's confirm text (or backend::lang.list.bulk_confirm with :action and :count), the action label as the confirm label, danger false, and the POST passed as the second argument of ask so the dialog stays open and busy; on 200 show the server message or backend::lang.list.bulk_done with the affected count, clear the selection, reload the list and bump partialReload; on 409 show backend::lang.list.bulk_stale, clear the selection and reload; on 403 show the server message or backend::lang.list.action_forbidden and keep the selection; on any other status show the server message or backend::lang.extension.action_failed, keep the selection and reload nothing. Server text is rendered through text interpolation only. Every string goes through t, tc or message.

(8) Phrase keys in modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with the UI-SPEC copy: list.bulk_actions, list.bulk_confirm, list.bulk_done (CLDR map: en one and other; pl one, few, many, other), list.bulk_stale, list.action_forbidden.

(9) SPA smoke test admin/tests/smoke/actions.smoke.test.ts with fixtures admin/tests/fixtures/roster.list-schema.json and roster.list.json: mount ListView; with nothing selected the trigger is disabled; select two rows, open the menu, choose activate, confirm; assert the POST path ends with /bulk/activate, the body ids are the two selected ids, the success toast shows, and the selection is cleared; a schema whose bulkActions holds only delete renders no menu; a label containing markup characters is rendered as text.

(10) Docs and READMEs in the same commit: modules/pact/README.md API reference (the four new identifiers); modules/cabana/README.md (Features, the Admin API routes table row for the bulk route, API reference: BulkActionResult, CRUDService.BulkAction); docs/backend/admin-controllers.md new section "Bulk actions" after "Toolbar actions" with a Go fence that is a src= reference to modules/cabana/example_actions_test.go and a YAML fence that is a src= reference to the roster config_list.yaml; docs/backend/lists-and-filters.md (the bulkActions key, the showCheckboxes rule, the 409 rule); docs/backend/admin-spa.md (the bulk menu in one paragraph). Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the source. go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestBulkAction|TestListSchemaBulkActions|TestBulkDelete|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance|TestPhase101)' -count=1 -v && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the cabana run lacks the line "--- PASS: TestBulkActionTracer" or prints "no tests to run"; TestPhase10SPAKeysResolve prints a line ending in "does not resolve"; vitest prints "FAIL" or "No test files found"; check-admin-openapi.sh prints "committed admin OpenAPI output is stale"; check-admin-dist.sh prints "modules/boardwalk/dist is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/pact AdminBulkAction and go doc ./modules/pact HasAdminBulkActions each print a declaration. - grep -c 'requireAjax(s.bulkAction)' modules/cabana/http.go prints 1. - grep -c '/{vendor}/{plugin}/{controller}/bulk/{action}' modules/cabana/security_coverage_test.go prints 1 and grep -c 'bulk/{action}' admin/openapi/admin.json prints at least 1. - go test ./modules/cabana -run '^TestBulkActionTracer$' -count=1 -v prints "--- PASS: TestBulkActionTracer"; the test asserts that the spy received loaded records, that a mixed in-scope and out-of-scope selection answers 409 with no row changed, and that an undeclared name answers 404. - go test ./modules/cabana -run '^TestBulkDelete' -count=1 passes unchanged (bulk delete keeps working as before). - npm --prefix admin test -- tests/smoke/actions reports the smoke file passed. - grep -c 'bulk_stale' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - git diff --stat PLAN_START_SHA -- go.mod go.sum admin/package.json admin/package-lock.json prints nothing after the task's commit, where PLAN_START_SHA is the commit sha the executor recorded with git rev-parse HEAD before starting Task 1. </acceptance_criteria> A plugin can declare a bulk action in Go and YAML, and an admin can run it on selected rows from the embedded SPA; ids outside the list scope never reach plugin code.

Task 2: One record offers named record actions that apply to its state, and running one changes the scoped record in a transaction D-10 (user-confirmed): the pact contract, config_form.yaml and the record response grow by `AdminRecordAction`, `recordActions` and `meta.actions`. modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/example_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/RecordActions.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/forms.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S2, Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (actions.go "Record action scope", http.go "Per-principal filtering", FormView "Record action request flow"), modules/pact/capabilities.go, modules/cabana/actions.go, modules/cabana/crud.go (ShowRecord, loadRecord, writeCRUDError, partialSelection), modules/cabana/form_schema.go (formConfigDocument, CompileForm), modules/cabana/relation_field.go (RecordMeta, RecordEnvelope, projectRelationFields), modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, admin/src/views/FormView.vue (onDelete), admin/src/components/ui/Button.vue, admin/src/components/ui/confirm.ts, admin/src/api/types.ts, docs/backend/forms.md Per D-10; UI-SPEC S2. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) pact: AdminRecordAction with Name, Label, Confirm, Permissions, Applies of type func(ctx context.Context, record any) (bool, error) and Run of type func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error), both function fields tagged json "-"; AdminRecordActionInput with RecordID uint64 and Record any (the record loaded through FormExtendQuery with a row lock); AdminRecordActionResult with Message string; interface HasAdminRecordActions with AdminRecordActions() []AdminRecordAction. Doc comments state: a nil Applies means the action always applies; Applies must be a pure read (it also runs on the show route); names are unique among the controller's record actions; create and delete are reserved.

(2) Compile. CompiledController gains RecordActions map[string]pact.AdminRecordAction; compileRecordActions(ctl) in extension.go with the same four boot errors; compileContributions validates their Permissions (owner text "record action ID.NAME"). form_schema.go: formConfigDocument gains yaml key recordActions decoded as the bulkActions list is (sequence of names, duplicates and scalars refused); FormSchema keeps the declared names in an unexported ordered field; compileExtension (which sees the controller) refuses a declared name the controller does not register ("recordActions: unsupported action NAME (want a record action the controller registers)") and an empty Label ("recordActions: action NAME needs a label") with bootErr naming config_form.yaml.

(3) Offered actions. schema_types.go: RecordAction with Name, Label and Confirm (json name, label, confirm with omitempty on confirm). relation_field.go: RecordMeta gains Actions []RecordAction with json key actions and omitempty, so responses without offered actions keep their bytes. crud.go ShowRecord: inside its read transaction, with the transaction placed on the context through withTx, build the offered list in declared order from the form's declared record actions that pass Allows for the principal on the context (bouncer.User) and whose Applies is nil or reports true; Label and Confirm are localized with the service translator; an Applies error is a lifecycle failure. Create and update responses leave Actions empty.

(4) Run. crud.go: CRUDService.RecordAction(ctx, cc, id, action): lagoon.Transaction, withTx, newWritableModel, coercePK, loadRecord (missing and out-of-scope ids are the same recordNotFound), then Applies (false returns a new unexported conflict error that writeCRUDError maps to 409 "conflict", like partialSelection), then Run with RecordID and Record; the result Message is localized. actions.go: recordActionOf(cc, name) (declared in the form and registered) and handler recordAction: protect, recordActionOf (404), the shared own-permission check (403), pathID, decodeActionRequest with the rule that record_id and values must be absent (422 "A record action takes no record_id or values."), the service call, writeCRUDError on error, WriteData 200 with AdminActionResult whose Fill is an empty object. Log one slog.Info line "cabana: admin record action" with controller id, action name, admin id and record id. http.go mount: POST /{vendor}/{plugin}/{controller}/{id}/actions/{action} wrapped in requireAjax with constrainController and the action identifier constraint.

(5) Contract. admin_openapi.go: stub AdminRecordAction modelled on AdminToolbarAction (path parameters id and action, body AdminActionRequest described as an empty object, success Envelope[AdminActionResult], failures 401, 403, 404, 409, 422, router /{vendor}/{plugin}/{controller}/{id}/actions/{action} post); the AdminShow description gains one sentence on meta.actions. Add the route row to phase09Routes. Regenerate with scripts/check-admin-openapi.sh; add alias RecordAction to admin/src/api/types.ts.

(6) Fixture and smoke test. The roster controller registers record actions activate (permission acme.roster.manage; Applies when active is false; sets active true) and reinstate (Applies when banned is true; sets banned false); config_form.yaml declares recordActions [activate, reinstate]. TestRecordActionSmoke: show of an inactive person lists activate and not reinstate for the full admin, and no action for the limited admin; POST {id}/actions/activate answers 200 and the row is active; a second POST answers 409; a person of another tenant answers 404; the limited admin gets 403; a body with record_id answers 422; an undeclared name answers 404. Extend example_actions_test.go with one record action.

(7) SPA per UI-SPEC S2. New admin/src/components/form/RecordActions.vue: props source (controller params), recordId, actions (RecordAction list from the record response) and disabled; renders one Button variant outline size md per action in the given order with data-record-action set to the name and whitespace-nowrap; a click asks through useConfirm with the action's confirm text or backend::lang.form.action_confirm with :action, the action label as confirm label, danger false, and the POST as the run argument; emits busy (true while a request runs), done with the toast text (server message or backend::lang.form.action_done), stale on 409 after showing backend::lang.form.action_stale, gone on 404, and on 403 or any other failure shows a danger toast with the server message or backend::lang.list.action_forbidden (403) or backend::lang.extension.action_failed. While one action runs every button it renders is disabled. It renders nothing when actions is empty. Phrase keys form.action_confirm, form.action_done, form.action_stale in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/actions.smoke.test.ts with fixture roster.record.json: mounting RecordActions with two offered actions renders two buttons in order; confirming one posts to .../{id}/actions/{name} with body {} and emits done; a 409 emits stale.

(8) Docs and READMEs in the same commit: modules/pact/README.md; modules/cabana/README.md (route table row, API reference: RecordAction, CRUDService.RecordAction); docs/backend/admin-controllers.md section "Record actions" (Applies, permissions, 404 and 409 outcomes, a src= Go fence); docs/backend/forms.md (the recordActions key of config_form.yaml and meta.actions on the show response). Rebuild and commit modules/boardwalk/dist. go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestRecordAction|TestBulkAction|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance|TestPhase101)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestRecordActionSmoke" or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; check-admin-openapi.sh prints "is stale"; check-admin-dist.sh prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/pact AdminRecordAction prints a struct with the fields Name, Label, Confirm, Permissions, Applies and Run. - grep -c 'requireAjax(s.recordAction)' modules/cabana/http.go prints 1 and grep -c '{id}/actions/{action}' modules/cabana/security_coverage_test.go prints 1. - go test ./modules/cabana -run '^TestRecordActionSmoke$' -count=1 -v prints "--- PASS: TestRecordActionSmoke"; the test asserts 404 for an out-of-scope record, 409 when Applies is false and 403 for the limited admin. - The JSON body of a show response for a controller without record actions has no actions key in meta (asserted in TestRecordActionSmoke against a second fixture controller or the existing acme.demo fixture). - grep -c 'action_stale' modules/phrasebook/backend/lang/en/lang.yaml prints 1. - test -f admin/src/components/form/RecordActions.vue succeeds and npm --prefix admin test -- tests/smoke/actions reports the smoke file passed. </acceptance_criteria> A plugin can declare record actions with their own permissions and an applicability rule; the server offers and runs them only for records in the form scope, and the SPA component that renders them is ready to be mounted on the preview screen.

Task 3: List rows show their state (deleted, negative, disabled) as text badges, from one controller call per page D-12: an optional controller hook and an optional meta key; adding a state later is additive. modules/pact/capabilities.go, modules/pact/capabilities_test.go, modules/pact/README.md, modules/cabana/query.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/messages.go, modules/cabana/list_schema.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/list/RowStateBadges.vue, admin/src/components/list/DataTable.vue, admin/src/views/ListView.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.list-schema.json, admin/tests/fixtures/roster.list.json, modules/boardwalk/dist, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S4, Color, Typography), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfall 7; "Row state must be a batch hook"), modules/pact/capabilities.go, modules/cabana/query.go (ExecuteList, ListMeta, ListResult), modules/cabana/crud.go (save, Delete, BulkDelete, deleteRecord, loadRecord, lockScoped), modules/cabana/http.go (list handler, projectRow), modules/cabana/messages.go, modules/cabana/list_schema.go (Localize), modules/cabana/messages_test.go, admin/src/components/list/DataTable.vue, admin/src/components/list/CellValue.vue, admin/src/views/ListView.vue, admin/src/styles/main.css, admin/tests/list/DataTable.test.ts, docs/backend/lists-and-filters.md Per D-12; UI-SPEC S4. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) pact: type RowState (string) with constants RowStateDeleted, RowStateNegative and RowStateDisabled holding the values deleted, negative and disabled; interface ListRowStates with method ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error). Doc comment: the framework calls it once per list page with the page's records in page order and the list's database handle (a list does not run in a transaction, so cabana.TxFromContext is not available); the result is index-aligned with records; a row may carry several states; values outside the three constants are dropped.

(2) cabana. query.go: ListMeta gains RowStates map[string][]string with json key row_states and omitempty. ExecuteList: after the page is loaded, when the controller implements pact.ListRowStates call it exactly once with the request context, the handle used for the list and the page's record pointers; a returned error or a result whose length differs from the record count fails the list (the handler answers the generic 500); for each row keep only the three known values, de-duplicated, in the fixed order deleted, negative, disabled; an unknown value is dropped and reported with slog.Warn naming the controller and the value; rows with no state are left out of the map; the map key is the row's primary key as a decimal string. http.go list handler: add "row_states" to the meta map only when the map is non-empty. messages.go: listMessageKeys, ListMessages and listMessageDefaults gain RowStateDeleted, RowStateNegative and RowStateDisabled (yaml keys rowStateDeleted, rowStateNegative, rowStateDisabled; JSON keys the same; defaults backend::lang.messages.list.row_state_deleted, row_state_negative, row_state_disabled). The AdminList swag description gains one sentence on meta.row_states. Regenerate the OpenAPI outputs.

(3) Phrase keys in en and pl: messages.list.row_state_deleted "Deleted" / "Usunięty", row_state_negative "Blocked" / "Zablokowany", row_state_disabled "Inactive" / "Nieaktywny".

(4) Fixture and smoke test: the roster controller implements ListRowStates (deleted when deleted_at is set, negative when banned, disabled when not active) and both its ListExtendQuery and its FormExtendQuery include soft-deleted rows (an unscoped query narrowed to tenant acme), as a Winter controller with withTrashed does; TestRowStateSmoke asserts the list meta carries the expected states for three seeded rows, that a row with two states lists them in the fixed order, that the hook ran exactly once for the page, and that a fixture controller returning an unknown value sends no such value; a controller without the hook has no row_states key. TestSoftDeletedRecordSmoke asserts that a soft-deleted person reached through that scope can be shown, updated (200, the changed column is stored and deleted_at is kept), targeted by a bulk action and a record action, and deleted through the form delete route and bulk delete, with the controller's FormAfterDelete removing the row for good inside the same transaction. If the current write path refuses any of these, change crud.go so the row write and the delete of a record use the scope its load used (a record loaded through the controller's scope is always writable and deletable through it); records of controllers that do not include soft-deleted rows must behave exactly as before.

(5) SPA per UI-SPEC S4. New admin/src/components/list/RowStateBadges.vue: props states and labels; renders one badge per known state in the fixed order with data-row-state set to the state and the classes of the S4 badge geometry and per-state colours (deleted: border-border-strong and text-muted; negative: bg-danger-soft and text-danger; disabled: bg-subtle and text-muted); badges are never struck through; an unknown state renders nothing. DataTable.vue: props rowStates (map of row id to state list, default empty) and stateLabels; each row element gets data-row-states with the known states joined by a space (the attribute is absent without states); the first cell becomes a flex row with gap 2 holding the link or text (min-w-0 truncate) and then the badges; text styles per the S4 table (deleted: first-cell text line-through and every cell text-muted; negative: first-cell text text-danger; disabled: every cell text-muted, first cell keeps weight 600), combining when states combine; the row background, selection, sorting, links and row click are unchanged. ListView.vue passes meta.row_states and the three localized labels from schema.messages. Extend the smoke test and the roster list fixtures: a row with deleted and negative renders two badges in order and the data-row-states attribute; an unknown state from the server renders no badge and no class.

(6) Docs and READMEs in the same commit: modules/pact/README.md (RowState, ListRowStates); modules/cabana/README.md (Features); docs/backend/lists-and-filters.md new section "Row state" (the hook, the fixed set, the three messages keys, a src= Go fence from example_actions_test.go extended with a ListRowStates example); docs/backend/admin-spa.md (badges in one sentence). Rebuild and commit modules/boardwalk/dist. go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestRowState|TestSoftDeletedRecord|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestRowStateSmoke" or "--- PASS: TestSoftDeletedRecordSmoke", or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/pact ListRowStates prints the interface and go doc ./modules/pact RowStateNegative prints the constant. - go test ./modules/cabana -run '^TestRowStateSmoke$' -count=1 -v prints "--- PASS: TestRowStateSmoke"; the test asserts one hook call per page and the fixed state order. - go test ./modules/cabana -run '^TestSoftDeletedRecordSmoke$' -count=1 -v prints "--- PASS: TestSoftDeletedRecordSmoke"; the test asserts that a soft-deleted record in the controller's scope can be shown, updated, acted on and permanently deleted through the admin API. - grep -c 'row_states' admin/src/api/schema.d.ts prints at least 1. - grep -c 'row_state_negative' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - npm --prefix admin test -- tests/smoke/actions reports the smoke file passed, including the case where an unknown state renders no badge. </acceptance_criteria> A controller can mark list rows with states from a fixed set in one call per page, and the admin sees each state as a text badge with the token styling of the design system.

Task 4: Plugin code refuses a write with a readable 403: a banner on a refused save, a toast on a refused action, and nothing is saved D-27 G4 (user-confirmed): `cabana.ForbiddenError` becomes a public error type every plugin hook and action may return. modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/FormErrorBanner.vue, admin/src/views/FormView.vue, admin/tests/smoke/actions.smoke.test.ts, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/users-and-permissions.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6 "Forbidden save", Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (crud.go "Error type pattern"; "Error outcomes a plugin may produce"), modules/cabana/crud.go (ValidationError, writeCRUDError, lifecycleFailure, save, Delete, BulkDelete), modules/cabana/actions.go (runAction), modules/cabana/relation.go and modules/cabana/relation_child.go (where relation hook errors are classified and written), modules/cabana/crud_lifecycle_test.go, admin/src/views/FormView.vue (save, showErrors, onDelete), admin/src/components/form/FormErrorBanner.vue, admin/src/components/form/formState.ts (fieldErrors, focusField), admin/tests/form/FormView.test.ts, docs/backend/admin-controllers.md (Hooks), docs/backend/users-and-permissions.md Per D-27 (G4, error half) and D-07's "refused with a forbidden error and changes nothing"; UI-SPEC S6 "Forbidden save". Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) crud.go: exported ForbiddenError with fields Message string (a phrase key or text; may be empty) and Details map[string]any (field name to a list of messages, each a phrase key or text), and a pointer-receiver Error method returning "forbidden", in the form of ValidationError. Classification, in every place an error is classified: writeCRUDError gains a branch that answers 403 with code forbidden, the error's Message and its Details through WriteErrorDetails; lifecycleFailure returns a *ForbiddenError unchanged (otherwise a hook's error would become the opaque lifecycle error); runAction in actions.go answers it as writeCRUDError does instead of the generic 500. The relation manager paths (link, unlink and the child create, update and delete hooks in relation.go and relation_child.go) let a *ForbiddenError through to the same 403 wherever they already let a *ValidationError through. Localization: the service that ran the write (CRUDService, RelationService) resolves Message and every Details string with its translator in the request locale before the error leaves it; runAction does the same with the handler's translator; an empty Message stays empty. A ForbiddenError returned inside a transaction rolls the whole write back (no partial save). Every other non-validation error keeps the opaque 500 and its log line; error text never reaches the client.

(2) Swag: add the 403 description sentence to AdminCreate, AdminUpdate, AdminDelete, AdminBulkDelete, AdminBulkAction and AdminRecordAction ("also returned when controller code refuses the write; details may name fields"). Regenerate the OpenAPI outputs (no schema type changes are expected; the check must stay clean).

(3) Fixture and smoke tests in phase121_actions_test.go: the roster controller's FormBeforeUpdate returns a ForbiddenError with a phrase-key Message and Details on name when the submitted name equals a sentinel value, its bulk action archive and record action reinstate return one for a sentinel row. TestForbiddenSmoke: the update answers 403 with code forbidden, the localized message and details.name, and the row is unchanged; the bulk action answers 403 and no selected row changed; the record action answers 403 and the row is unchanged; a hook returning a plain error still answers 500 with the generic body and no error text.

(4) SPA per UI-SPEC S6 "Forbidden save". FormErrorBanner.vue gains the optional prop forbidden (string or null): when set it renders the banner geometry with data-forbidden-banner, role alert, the CircleAlert icon and the text; the 422 banner is unchanged. FormView.vue: on a 403 from create or update keep every entered value and the dirty state, set the forbidden text to the server error.message when it is non-empty, else backend::lang.form.forbidden, apply error.details to the fields through fieldErrors and focus the first such field (switching tab when needed) as showErrors does, and show no toast; clear the forbidden text at the start of the next save attempt. A 403 on the form's delete shows a danger toast with the server message or backend::lang.list.action_forbidden. Phrase keys form.forbidden in en and pl with the UI-SPEC copy. Extend the smoke test: a 403 on save renders the banner with the server message, keeps the typed value and marks the detailed field; a 403 with an empty message renders the form.forbidden text; a second save attempt removes the banner before the request settles.

(5) Docs and READMEs in the same commit: modules/cabana/README.md API reference (ForbiddenError); docs/backend/admin-controllers.md section "Refusing a write" (ValidationError is 422, ForbiddenError is 403 and rolls back, anything else is the opaque 500); docs/backend/users-and-permissions.md (action permissions on bulk and record actions, and the difference between a permission denial and a ForbiddenError). Rebuild and commit modules/boardwalk/dist. go vet ./... && go test ./modules/cabana -run '^(TestForbidden|TestBulkAction|TestRecordAction|TestRowState|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./... -count=1 && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestForbiddenSmoke" or prints "no tests to run"; the full go test run prints a line starting with "FAIL"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/cabana ForbiddenError prints a struct with the fields Message and Details. - go test ./modules/cabana -run '^TestForbiddenSmoke$' -count=1 -v prints "--- PASS: TestForbiddenSmoke"; the test asserts status 403, code forbidden, the unchanged row after each refused write, and a generic 500 body for a plain hook error. - grep -c 'data-forbidden-banner' admin/src/components/form/FormErrorBanner.vue prints 1. - grep -c 'forbidden:' modules/phrasebook/backend/lang/pl/lang.yaml prints at least 1. - go vet ./... &amp;&amp; go test ./... -count=1 exits 0 in summercms.go at the task's commit. </acceptance_criteria> Plugin hooks and actions can refuse a write with a 403 the admin can read; the refused write changes nothing, and the SPA shows a persistent banner on a refused save and a toast on a refused action.

<threat_model>

Trust Boundaries

Boundary Description
Admin browser → admin API (new POST routes) Untrusted ids, action names and bodies cross here; cookie auth needs the CSRF header
cabana → plugin action code The framework hands over only records it loaded through the controller's scope
Plugin code → admin browser Action labels, confirm texts, messages, error text and row states are rendered in the SPA

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12.1-01 Elevation of Privilege bulk action ids (IDOR) high mitigate CRUDService.BulkAction resolves ids with lockScoped (ListExtendQuery) before Run; Run receives records, never ids; a partial match is 409 and rolls back (Task 1, TestBulkActionTracer).
T-12.1-02 Elevation of Privilege running an action without its permission, or an undeclared action high mitigate bulkActionOf / recordActionOf (404 unless declared in YAML and registered), the shared own-permission check (403 plus auth log), per-principal filtering of the list schema and meta.actions, boot validation of action permissions (Tasks 1, 2).
T-12.1-03 Tampering CSRF on the two new POST routes high mitigate Both routes are mounted with requireAjax under the backend guard; the cookie-only auth mode is asserted 403 (Tasks 1, 2).
T-12.1-04 Elevation of Privilege record action on a record outside the form scope or in the wrong state high mitigate loadRecord with FormExtendQuery and a row lock (one 404 for missing and out-of-scope), Applies re-checked inside the transaction (409) (Task 2).
T-12.1-05 Tampering half-applied bulk mutation medium mitigate One lagoon.Transaction per request; any error, including ForbiddenError, rolls every row back (Tasks 1, 4).
T-12.1-06 Information Disclosure internal error text in 403 or 500 bodies medium mitigate Only ForbiddenError.Message and Details (plugin-authored phrase keys or text) are written; every other error keeps the opaque 500 and a server-side log (Task 4, TestForbiddenSmoke).
T-12.1-07 Tampering plugin-supplied row state or label used as markup or CSS class low mitigate Row states are reduced to a fixed set on the server and again in the SPA; labels, confirms and messages are rendered through text interpolation only (Tasks 1, 3).
T-12.1-08 Repudiation bulk and record actions leave no trace low mitigate One slog.Info line per run with controller, action, admin id and affected count or record id; no record contents (Tasks 1, 2).
T-12.1-SC Tampering npm/pip/cargo installs high mitigate No package is installed or bumped by this plan; the acceptance check on go.mod and admin/package.json proves it. Any need for a package stops the plan at a blocking human checkpoint.
</threat_model>
- `go vet ./... && go test ./... -count=1` green in summercms.go after every task commit. - `npm --prefix admin run typecheck && npm --prefix admin test` green; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean after every task commit. - `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` pass. - `go test ./modules/cabana -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance)$' -count=1` passes with the two new routes. - The application still builds against the working tree: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...`.

<success_criteria>

  • Bulk actions, record actions, row state and ForbiddenError exist with the names in "Artifacts this phase produces" and behave as the D-09, D-10, D-12 and D-27 (G4 error half) truths state.
  • The SPA shows the bulk menu, the row-state badges and the forbidden banner as UI-SPEC S1, S4 and S6 describe; RecordActions.vue implements S2 and is ready for the preview footer.
  • READMEs, docs, the OpenAPI document, the TS types and dist/ match the code; no consuming application is named; no dependency changed. </success_criteria>
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md` when done. Record the final names of every contract (they are inputs to plans 02 to 05) and the measured run time of the quick commands for VALIDATION.md.