diff --git a/.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md b/.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
new file mode 100644
index 0000000..5e8a75b
--- /dev/null
+++ b/.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
@@ -0,0 +1,882 @@
+# Phase 12.1: User plugin admin screens - Research
+
+**Researched:** 2026-10-04
+**Domain:** cabana admin pipeline (Go + Vue SPA) and the `golem15.user` plugin port of three WinterCMS backend screens
+**Confidence:** HIGH for the current code shape and the PHP inventory (all read this session); MEDIUM for the recommended new contracts (design choices inside Claude's discretion, several need user confirmation)
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Impersonate and guests
+- **D-01:** Impersonate is not ported: no action, no button. PHP impersonation is session-based and never reaches the JWT-driven frontend, so nothing is lost at cutover.
+- **D-02:** The permission `golem15.users.impersonate_user` is still registered by the Go plugin, so backend roles imported at cutover that reference it stay valid and a later phase can use it. The plugin registers all four PHP codes: `golem15.users.access_users`, `access_groups`, `access_settings`, `impersonate_user`.
+- **D-03:** The guest concept is dropped entirely: no convert-guest action, no `is_guest` column or list column, no guest hint. The seeded Guest group row stays as data.
+
+#### Group membership and privileged groups (T-12-18)
+- **D-04:** `golem15.users.access_users` is enough to change a user's membership of ordinary groups, as in PHP. Adding or removing a **privileged** group additionally requires a new backend permission. The check is on the server and covers every path that writes `users_groups` (the user form's groups field, any relation manager, bulk or record actions). — **Reversibility:** costly — the permission code is stored in backend roles; renaming it later means rewriting role rows in every host application.
+- **D-05:** Privileged groups are a config list of group codes under the `golem15.user` config namespace, default `[admin]`. The plugin is shared across projects, so an application adds a code without a plugin change.
+- **D-06:** The User Groups screen is gated by `golem15.users.access_groups`. Creating a group with a privileged code, changing a code to or from a privileged one, and deleting a privileged group all require the same extra permission as D-04. Ordinary groups are edited freely.
+- **D-07:** For an admin without the extra permission, privileged groups are shown in the user form's groups field but locked (disabled), so the admin can see that a user is a site admin. A save that tries to change a privileged membership anyway is refused with a forbidden error and changes nothing (no partial save).
+- **D-08:** `User.Groups` stays out of every user API payload (P12 D-25). Only the admin API reads or writes it.
+
+#### Actions, delete semantics and preview (framework + plugin)
+- **D-09:** cabana gains **declared bulk actions**, generic for every plugin: a controller registers named bulk actions, the list declares which ones it offers, the SPA sends the selected ids, and the framework resolves them through the controller's list scope inside one transaction before calling the plugin. Ids outside the scope never reach the plugin. Bulk delete keeps working as today. — **Reversibility:** costly — list YAML and the `pact` action contract grow; every plugin's list config may come to depend on the shape.
+- **D-10:** cabana gains **record actions**: named actions shown as buttons for one record, run against the record loaded through the controller's form scope, each with its own permissions on top of the controller's, and each able to say whether it applies to the record's current state. — **Reversibility:** costly — same contract growth as D-09.
+- **D-11:** cabana gains Winter's **preview context**: a read-only record screen with its own toolbar, fields with `context: preview`, and a `recordUrl` that may point at it. The Users list opens preview first, then Update, as in PHP. Status hints (not activated, banned, suspended, deleted) and the record actions live on the preview screen. — **Reversibility:** costly — a new screen and route in the SPA and a new context value in the form schema.
+- **D-12:** cabana gains **row state**: a controller returns a state per list row from a fixed framework-defined set (Winter's `listInjectRowClass`, limited to known states such as deleted, negative, disabled, not free CSS classes). The SPA styles states with design tokens. Users: deleted when trashed, negative when banned, disabled when not activated.
+- **D-13:** Delete semantics follow PHP `Users.php` exactly. The Users list and form include trashed users (`withTrashed`). `deactivate` is the soft delete, `restore` brings a user back, and `delete` (form button and bulk) is a permanent force delete. No extra typed confirmation beyond the standard confirm.
+- **D-14:** Bulk actions on Users, as in PHP `index_onBulkAction`: delete, activate, deactivate, restore, ban, unban. Record actions: activate, unban, unsuspend. Ban and suspend state lives in the existing `user_throttle` table.
+
+#### Fields and columns
+- **D-15:** Frontend permissions are ported in full: the `golem15_user_frontend_permissions` table (code, label, tab, comment), the `users.permissions` column, the existing `user_groups.permissions` column, the Permissions tab on the user form (radio mode: allow / deny / inherit) and the group form (checkbox mode), and a Go resolver equivalent to PHP `User::getMergedPermissions()` that a plugin can call to ask whether a user holds a permission. Nothing in the application calls the resolver yet; unit tests prove it. No plugin-declared registry: rows come from migrations or seed, as in PHP. — **Reversibility:** costly — additive migrations in the shared user plugin; the stored JSON shape must match PHP for the cutover import.
+- **D-16:** The editor is a built-in framework field type, `permissioneditor`, in cabana and the SPA: tabbed, with a radio or checkbox `mode`, and an option list supplied by the controller. It replaces the PHP YAML's `Golem15\User\FormWidgets\FrontendPermissionEditor` class name. It is built to be reusable for backend role permissions later. The 10.1 widget contract (scalar fill keys only) is not widened. — **Reversibility:** costly — a new field type in the typed schema and the generated TS types.
+- **D-17:** `last_seen` is added to `users` as an additive column, updated by the Go auth path on login or token refresh, and shown in the Users list as in PHP. It must not appear in any user API payload unless PHP already returns it.
+- **D-18:** `username` is dropped from the ported YAML and no column is added (login is by email).
+- **D-19:** The user form carries the PHP create/update behaviour: password with confirmation on create, password reset on update, `send_invite` (default on, create only) that sends the invitation mail, and `created_ip_address` / `last_ip_address` on preview.
+- **D-20:** Avatars use the Phase 12.2 `fileupload` field (`mode: image`) on both the user form (260x260) and the organisation form (120x120). They must bind to the same attachment the user API already serves, so an avatar set in the admin shows in the app and the other way round.
+- **D-21:** `block_mail` and MailBlocker are left out. A todo records them for the mailing work (mail settings, templates).
+
+#### Screens, as ported
+- **D-22:** Organisations: list plus form (name, slug with preset from name, description, avatar) gated by `golem15.users.access_users`, and a `members` relation manager that assigns existing users (PHP `add|remove` on the hasMany, which sets or clears the user's `organisation_id`). The user form's `organisation` field is a belongsTo `relation` picker.
+- **D-23:** Users filters follow PHP `config_filter.yaml`: groups (scope `filterByGroup`), created date (daterange) and activated (switch). The PHP `conditions:` strings are re-expressed as model scopes, since a `conditions` key is a boot error (P9).
+- **D-24:** User Groups list keeps the `users_count` column.
+
+#### Release and ordering
+- **D-25:** Framework first. The cabana and SPA work (D-09 to D-12, D-16) lands in the early plans with module READMEs, `docs/` pages, the admin OpenAPI document, generated TS types and the rebuilt `dist/`, and is tagged **v0.1.3** (`v0.1.2` already exists). The plugin screens build on that tag. Framework fixtures use neutral names and never name the application.
+
+### Claude's Discretion
+- The code of the extra permission in D-04 (for example `golem15.users.manage_privileged_groups`), its label and tab, and the config key name for the privileged list.
+- YAML keys and Go interface names for bulk actions, record actions, preview and row state, provided they follow the existing fail-loud rules (unknown keys and unregistered actions are boot errors), sit under the `{prefix}/api/v1/{vendor}/{plugin}/{controller}/...` scheme, use `requireAjax` on writes and carry swag annotations.
+- The exact set of row states and their token styling; whether a state is also announced in text for accessibility.
+- What the preview screen shows beyond status hints, preview-context fields and actions (PHP's scoreboard is optional).
+- Whether `last_seen` is written on login only or also on refresh, and any throttling of that write.
+- How force delete cleans up what hangs off a user (attachments, throttle, groups pivot), following PHP `User` delete behaviour.
+- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Run the security-review agent: the phase touches authorization (T-12-18), the plugin API (`pact`) and destructive bulk operations.
+
+### Deferred Ideas (OUT OF SCOPE)
+- Impersonate a frontend user from the admin by minting a short-lived user JWT, with an audit record and a Nuxt entry point. The permission is already registered (D-02).
+- MailBlocker and the block-mail checkbox: recorded as todo `mailblocker-with-mailing.md`, to be done with the mailing development.
+- Guest users and convert-guest, for a project that uses guests.
+- A `username` column and login by username.
+- A plugin-declared frontend permission registry synced at boot.
+- Using `permissioneditor` for backend role permissions.
+- The user plugin's Settings screen (`access_settings`).
+
+#### Reviewed Todos (not folded)
+- `nest-framework-packages-under-modules.md`, `per-module-readmes-after-nest.md`, `readme-go-fences-src.md`, `rewrite-summercms-readme.md`, `refresh-fonoteka-readme.md`: repo and README housekeeping; keyword matches only.
+- `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`: benchmarking, unrelated.
+- `backend-admin-api-tokens.md`: backend admin auth, not frontend user administration.
+- `orphan-pending-routes.md`: covered by Phase 14.1.
+- `scaffold-admin-controller-incomplete.md`, `scaffold-same-second-migration-order.md`, `scaffold-generated-header-and-command-deps.md`, `bonfire-duplicate-command-names.md`: CLI scaffolding bugs; worth knowing if the planner uses `make:admin-controller`, but not this phase's scope.
+- `lagoon-readme-after-commit-callback-order.md`, `sitemap-plugin-port.md`: unrelated.
+
+
+## Summary
+
+All research for this phase was done by reading the two Go repositories and the PHP reference. No external library is added and no web lookup was needed, so the research-plan/Context7 seam was not used; every claim below is either read from a file this session or marked `[ASSUMED]`.
+
+The five framework features in CONTEXT.md (bulk actions, record actions, preview, row state, `permissioneditor`) all have clean seams in cabana: one action namespace (`CompiledController.Actions`), a transaction-scoped id resolver that bulk delete already uses (`lockScoped`), a scoped single-record loader (`loadRecord` / `readScopedRecord`), a form `context` list that already accepts any identifier, and a typed schema that flows to the SPA through swag, `swagger2openapi` and `openapi-typescript`. The contract tests `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix` and `TestPhase10OpenAPIConformance` force the route table, the permission matrix and the OpenAPI document to stay in step.
+
+**The main finding is that those five features are not enough.** Reading the Users form field by field against cabana shows eight further gaps that block locked decisions (D-07, D-13, D-19, D-22, D-24). The most serious: a controller hook never sees form values that are not model columns, so `password`, `password_confirmation` and `send_invite` cannot reach the plugin; `password`, `permissions`, `is_activated` and `organisation_id` are hard-coded protected fill keys, which makes the `organisation` picker read-only and the password unwritable; a hook cannot answer 403; a relation field has no way to show an option as locked; and the `User.Rules()` the admin save would validate against demands `password|confirmed`, which fails on every admin update. Each gap has a recommended answer below, but four of them grow the `pact`/cabana contract and need the user's confirmation at the plan-count checkpoint.
+
+**Primary recommendation:** Plan the framework half as "five decided features plus a small set of enabling seams" (form virtual fields with a `password` type, an explicit writable-foreign-key opt-in, locked relation options, a 403 error type, `preset`, `invisible` columns), tag v0.1.3, then port the three screens with flattened YAML and plugin-side hooks. Present the extra seams to the user before writing plans.
+
+## Project Constraints (from CLAUDE.md)
+
+- Lean planning: few, large plans. Present the plan count with a one-line scope each and wait for confirmation before writing PLAN.md files.
+- Unit tests are the last plan of the phase. Earlier plans may carry smoke tests.
+- Standard library first. A dependency is added only when research or a phase decision names it. **This research names none.**
+- `go vet` and `go test ./...` green at every commit, in both repositories.
+- Plugins are compiled; no runtime plugin loading.
+- API parity: the user API payloads must not change (D-08, D-17).
+- No co-author tags. One logical change per commit. Planning docs and code in separate commits.
+- A change to a module's exported API, config keys or CLI commands updates that module's `README.md` and the affected `docs/` pages in the same change. Every identifier named in a README or docs page must exist (`go test ./cmd/summer -run TestDocsTree`, `summer docs:build --check`).
+- Framework READMEs, docs and fixtures never name a consuming application; use `acme` / `blog`.
+- Config keys named in docs are not machine-checked; review by hand.
+- Two repositories: framework code in `summercms.go`, plugin code in `sm-user-plugin` (mounted at `../fonoteka.go/plugins/golem15/user`), planning docs in `summercms.go/.planning`.
+- Core plugin contract: `golem15.user` is shared across projects; migrations are additive, routes and payloads unchanged.
+- The admin SPA has an approved exact-pin package gate (17 pins at Phase 10, plus `@internationalized/date`): any new npm package or version needs a checkpoint. **This research recommends no new npm package.**
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Bulk action dispatch, id scoping, transaction | API / Backend (cabana) | — | D-09: ids must be resolved through the list scope before plugin code runs |
+| Bulk action business logic (ban, restore, activate) | API / Backend (plugin controller) | Database | Plugin owns `user_throttle` and `users` writes |
+| Record action dispatch, applicability check | API / Backend (cabana + plugin) | Browser (button rendering) | The server decides which actions apply; the SPA only renders the offered list |
+| Preview screen | Browser (SPA route and view) | API (schema flag, redirects) | Read-only rendering of data the show route already returns |
+| Row state | API / Backend (controller hook) | Browser (token styling) | State is derived from data the browser does not have (`user_throttle`) |
+| `permissioneditor` value validation and storage | API / Backend (cabana + model) | Browser (editor UI) | Codes and allowed values are validated against the controller's option list on the server |
+| Privileged-group authorization (T-12-18) | API / Backend | — | D-04: server-side on every `users_groups` write path; the locked UI is a display aid only |
+| Password hashing, invitation mail | API / Backend (plugin hook) | — | Reuses `bouncer.HashPassword` and the plugin's mail path |
+| Avatar storage | Database / Storage (`system_files` + blob bucket) | API (cabana file routes) | Same `attach.File` rows the user API writes |
+| `last_seen` write | API / Backend (user plugin auth handlers) | Database | D-17 |
+| Admin navigation and permissions | API / Backend (`pact.HasNavigation`, `HasPermissions`) | Browser | Registry filters navigation per principal |
+
+## Standard Stack
+
+No new library. Everything is already in the two module graphs.
+
+### Core (already present, verified in `go.mod` / `package.json` this session)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `modules/cabana`, `modules/pact`, `modules/lagoon`, `modules/bouncer` | in-repo | Admin pipeline, capability interfaces, data layer, auth | The framework this phase extends |
+| gorm.io/gorm | v1.31.2 `[VERIFIED: ../fonoteka.go/plugins/golem15/user/go.mod]` | ORM | Project decision |
+| go-gormigrate/gormigrate/v2 | v2.1.7 `[VERIFIED: same go.mod]` | Additive plugin migrations | Project decision |
+| goccy/go-yaml | v1.19.2 `[VERIFIED: same go.mod]` | Strict YAML decoding in cabana | Project decision |
+| swaggo/swag | v1.16.6 `[VERIFIED: scripts/check-admin-openapi.sh:33]` | Admin OpenAPI generation | Pinned in the generation script |
+| vue | 3.5.35 `[VERIFIED: admin/package.json]` | Admin SPA | Exact pin |
+| reka-ui | 2.9.10 `[VERIFIED: admin/package.json]` | Headless tabs, radio groups, checkboxes for the permission editor | Already the SPA's primitive library |
+| openapi-typescript | 7.13.0 `[VERIFIED: admin/package.json]` | TS types from the OpenAPI document | Exact pin |
+| vitest | 3.2.7 `[VERIFIED: admin/package.json]` | SPA unit tests | Exact pin |
+| testcontainers-go | v0.44.0 `[VERIFIED: plugin go.mod]` | Real Postgres in Go tests | Project decision |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| New `password` field type plus virtual fields | An admin-only wrapper struct embedding `models.User` | No contract growth, but hooks still cannot see `password_confirmation` or `send_invite`; does not solve D-19 on its own |
+| `preset` in the framework | Server-side slug default only (`BeforeValidate`) | Loses the live fill D-22 names; keeps v0.1.3 smaller |
+| `invisible` list columns | Show `surname` and the IP columns, or drop them | Losing them loses PHP's search on surname and IP |
+
+**Installation:** none.
+
+## Package Legitimacy Audit
+
+This phase installs no external package in either the Go modules or `admin/package.json`, so the legitimacy gate has nothing to check.
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none
+
+## Current Shape of the Framework (extension seams)
+
+### Routes (`modules/cabana/http.go:219-344`)
+
+Every controller route is registered in `service.mount` inside `r.GroupRaw(api, []string{"backend"}, ...)`; writes are wrapped in `requireAjax`. The relevant existing write routes, quoted verbatim `[VERIFIED: modules/cabana/http.go:254-272]`:
+
+```go
+g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
+g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
+g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
+g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))
+g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)
+g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
+g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
+g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
+```
+
+Six-segment GET routes share one pattern dispatched by `nestedGet` (`http.go:397-414`) because ServeMux cannot hold them side by side. New POST routes with a distinct literal segment do not collide with the existing POST patterns `[ASSUMED: the surf overlap check is not re-run here; TestPhase09ContractInventory will prove it]`.
+
+`service.protect` (`http.go:932-950`) resolves the controller, requires a backend principal and enforces `RequiredPermissions`. `service.allowAction` (`actions.go:120-132`) checks an action's own permissions on top and logs a denial.
+
+### Actions (`modules/cabana/actions.go`, `extension.go`, `modules/pact/capabilities.go:215-256`)
+
+- One namespace: `CompiledController.Actions map[string]pact.AdminAction`, built by `compileActions` (`extension.go:256-278`). Reserved names, verbatim `[VERIFIED: modules/cabana/extension.go:47]`: `var builtinToolbarActions = map[string]bool{"create": true, "delete": true}`.
+- `pact.AdminAction` fields, verbatim `[VERIFIED: modules/pact/capabilities.go:222-227]`: `Name`, `Label`, `Permissions`, `Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error)`.
+- `AdminActionInput` `[VERIFIED: capabilities.go:236-241]`: `Field`, `RecordID *uint64`, `Record any`, `Values map[string]any`. `AdminActionResult`: `Message`, `Fill`.
+- A toolbar action body must be `{}`; `record_id` or `values` is a 422 (`actions.go:93-96`). This is the property D-09 must keep: an id list never becomes an unscoped lookup.
+- `runAction` (`actions.go:137-155`) maps a `*ValidationError` to 422 and every other error to a logged 500.
+- Action permissions are validated at boot against registered permissions (`registry.go:225-229`).
+
+### Bulk delete, the model for D-09 (`modules/cabana/crud.go:197-247`, `550-583`)
+
+`BulkDelete` does `normalizeIDs` → `lagoon.Transaction` → `lockScoped` (applies `pact.ListExtendQuery`, `FOR UPDATE`, ordered by primary key) → zero rows is a no-op, a partial match is `partialSelection{}` (409) → per-row `deleteRecord`. The body type is `BulkDeleteInput{IDs []any}` and the result `BulkResult{Deleted int}` `[VERIFIED: crud.go:41-49]`. The transaction is put on the context with `withTx`, and a hook reads it with `cabana.TxFromContext(ctx)` (`tx_context.go:27`).
+
+`operationDeclared` (`registry.go:122-132`): `bulk-delete` needs the toolbar `delete` button, which needs `showCheckboxes: true`. The schema's `BulkActions` already exists as a list `[VERIFIED: schema_types.go:48-52, 82]` and today carries only `{Name: "delete", Label: "backend::lang.list.delete_selected"}` (`list_schema.go:152-155`).
+
+### Single-record scope, the model for D-10 (`crud.go:465-485`, `actions.go:192-215`)
+
+`loadRecord` applies `pact.FormExtendQuery`, locks `FOR UPDATE` and returns `recordNotFound{}` for missing and out-of-scope ids alike. `readScopedRecord` is the same without the lock. `writeCRUDError` (`crud.go:412-434`) knows exactly four outcomes: 413, 422 (`*ValidationError`), 404 (`recordNotFound`), 409 (`partialSelection`), else 500. **There is no error a hook or action can return to produce 403.**
+
+### List schema (`list_schema.go`, `schema_types.go`)
+
+- `config_list.yaml` keys accepted by `listDocument` `[VERIFIED: list_schema.go:26-43]`: `list`, `modelClass`, `title`, `recordUrl`, `noRecordsMessage`, `recordsPerPage`, `perPageOptions`, `showCheckboxes`, `showSetup`, `showSorting`, `showSearch`, `defaultSort`, `toolbar`, `filter`, `messages`, `headerPartial`. Decoding is strict: any other key is a boot error.
+- `toolbar.buttons` must be a YAML list; a scalar such as PHP's `list_toolbar` is refused with a message naming the Winter partial (`list_schema.go:325-327`).
+- `columns.yaml` per-column keys `[VERIFIED: list_schema.go:62-69]`: `label`, `searchable`, `sortable`, `type`, `relation`, `select`. Column types, verbatim `[VERIFIED: list_schema.go:22-24]`: `"text"`, `"datetime"`, `"switch"`, `"date"`, `"time"`. A column key that is not a model column (and has no `relation`/`select`) is a boot error.
+- Rows are projected by `projectRow` (`http.go:969-995`): `id` plus one key per column, read from the model by gorm column name.
+- `ExecuteList` (`query.go:81-155`) is not in a transaction; a list hook has no `TxFromContext`.
+
+### Form schema (`form_schema.go`, `crud.go`)
+
+- Field types, verbatim `[VERIFIED: form_schema.go:23-27]`: `"text"`, `"textarea"`, `"number"`, `"checkbox"`, `"switch"`, `"dropdown"`, `"relation"`, `"relation-manager"`, `"widget"`, `"partial"`, `"fileupload"`, `"datepicker"`.
+- Field keys, verbatim `[VERIFIED: form_schema.go:34-44]`: `label`, `comment`, `span`, `type`, `required`, `tab`, `context`, `attributes`, `size`, `default`, `nameFrom`, `emptyOption`, `options`, `relation`, `widget`, `action`, `fill`, `path`, `mode`, `fileTypes`, `mimeTypes`, `maxFilesize`, `maxFiles`, `imageWidth`, `imageHeight`, `thumbOptions`, `useCaption`, `prompt`, `format`, `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour`, `ignoreTimezone`. `type` is required. The fields file has one top-level key, `fields` (`formFieldsFile`, `form_schema.go:64-66`).
+- `mode` is refused on any type other than fileupload or datepicker `[VERIFIED: field_file.go:74]`: `"mode is only valid on type: fileupload or datepicker"`. `permissioneditor` must be added to that rule.
+- `config_form.yaml` keys `[VERIFIED: form_schema.go:49-62]`: `name`, `form`, `modelClass`, `defaultRedirect`, `create`, `update`, `messages`; `create`/`update` accept only `redirect` and `redirectClose`.
+- `context` accepts any identifier or list of identifiers (`compileContext`, `form_schema.go:555-576`), so `context: preview` already compiles. `contextAllows(cc, name, op)` (`crud.go:854-873`) is called with `create` or `update`, so a preview-only field is never writable.
+- Writable binding (`BindWritableFields`, `crud.go:103-128`): every scalar field (`scalarFormField`: text, textarea, number, checkbox, switch, dropdown, datepicker) must be a model column, else boot fails with `field X is not a model column`.
+- Protected fill keys, verbatim `[VERIFIED: modules/cabana/crud.go:833-843]`: `"id"`, `"created_at"`, `"updated_at"`, `"deleted_at"`, `"owner_id"`, `"user_id"`, `"collection_id"`, `"organisation_id"`, `"organization_id"`, `"scope_id"`, `"role_id"`, `"permissions"`, `"is_superuser"`, `"is_system"`, `"is_activated"`, `"password"`. A scalar field with one of these names is skipped silently; nested (map or list) values are always dropped by `ProjectWritableFields`.
+- Save pipeline (`crud.go:300-410`): lift relation values → transaction → load → `lagoon.Fill` → `BeforeValidate` → `lagoon.Validate(mergedRules)` → `FormBeforeCreate/Update` → `checkRelationScope` → `assignBelongsTo` → `Save/Create` → `syncBelongsToMany` → deferred file commit → `FormAfterCreate/Update` → project. Hooks receive `(ctx, model)` only.
+- `lifecycleFailure` (`crud.go:498-523`) lets a hook's `*cabana.ValidationError` through as 422; any other hook error becomes an opaque 500.
+
+### Relation fields and relation managers
+
+- `type: relation` needs a `cabana.FieldRelationContract` from `AdminFieldRelations()`. A belongsTo whose foreign key is a protected fill key is read-only `[VERIFIED: relation_field.go:198]`: `out.ReadOnly = protectedFillKey(contract.ForeignKey)`.
+- belongsToMany needs a pivot model (`NewPivot`), `ParentForeignKey`, `RelatedForeignKey`. Save replaces all pivot rows of the parent (`syncBelongsToMany`, `relation_field.go:497-537`): delete, then bulk insert. Submitted ids are revalidated through `RelationExtendOptionsQuery` (`checkRelationScope`).
+- `RelationOption` is `{Value uint, Label string}` `[VERIFIED: relation_field.go:52-55]`; there is no disabled or locked flag.
+- `relation-manager` needs `AdminRelationContracts()`; kinds `[VERIFIED: relation.go:29-35]`: `RelationBelongsToMany = "belongsToMany"`, `RelationHasMany = "hasMany"`. View-panel buttons, verbatim `[VERIFIED: relation.go:444]`: `"create"`, `"update"`, `"delete"`, `"link"`, `"unlink"`. A hasMany link sets the child's `ForeignKey` through `setModelColumn` (`relation.go:928-934`); unlink needs a nullable key.
+- `config_relation.yaml` lists columns inline under `view.list.columns` and `manage.list.columns`; each column key must be in the contract's `Columns` map (`relation.go:413-415`). A path string for `list:` does not decode.
+- There is a `RelationBeforeLink` hook and six child-record hooks; there is **no unlink hook**.
+
+### File upload (D-20)
+
+`compileFileFields` (`field_file.go:283-365`) requires the record model to implement `attach.Owner` (`MorphName`) and `attach.HasRelations`, and to declare a relation whose `Name` equals the field name. `attach.Relation` is `{Name string, Many bool, Public bool}` `[VERIFIED: modules/lagoon/attach/relation.go:9-13]`. cabana finds a record's files with `attachment_type = ? AND attachment_id = ? AND field = ?` (`field_file.go:522`). The user API writes avatars with exactly those three values (`Field: "avatar"`, `AttachmentID` the decimal user id, `AttachmentType: user.MorphName()`) `[VERIFIED: ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go:1153-1161]`. So one `AttachRelations()` method on `models.User` binds the admin field to the API's avatar, in both directions.
+
+### SPA (`admin/src`)
+
+- Field registry: `components/form/registry.ts` maps type → component in a `Map`; `valueless` types are left out of the save body. A new field type is one component under `components/form/fields/` plus one registry line.
+- Routes (`app/router.ts`): `list`, `create`, `record` (`/:vendor/:plugin/:controller/:id(\d+)`), all rendering `ListView` or `FormView`. `CONTROLLER_ROUTES` (`new Set(['list', 'create', 'record'])`) drives plugin stylesheet activation and must gain any new controller route.
+- `app/winterUrl.ts` maps only `create` and `update/:id`; anything else (including PHP's `preview/:id`) falls back to the list.
+- `views/ListView.vue` holds selection, bulk delete (`onDelete`) and toolbar actions (`onAction`); `components/list/ListToolbar.vue` renders delete and registered toolbar buttons; `components/list/DataTable.vue` renders rows (`
` at line 232).
+- `views/FormView.vue` filters fields with `contextAllows(field, mode)` where mode is `create` or `update`.
+- Types are aliases onto the generated schema (`api/types.ts`); nothing is hand-written.
+
+### OpenAPI → TS types → `dist/` pipeline
+
+1. Add or change swag annotations in `modules/cabana/admin_openapi.go` (stub functions with `// @Router` comments; request/response types are real Go types in package cabana).
+2. `scripts/check-admin-openapi.sh` regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`; `--check` fails on drift.
+3. Add aliases in `admin/src/api/types.ts`.
+4. `npm --prefix admin run build` writes `modules/boardwalk/dist`; `scripts/check-admin-dist.sh` rebuilds into a temp dir and diffs. The rebuilt `dist/` is committed with its source in the same commit (12.2 rule: "Every task rebuilds `modules/boardwalk/dist` ... so `scripts/check-admin-dist.sh` stays clean at every commit" `[VERIFIED: .planning/phases/12.2-.../12.2-04-PLAN.md:124]`).
+5. Tailwind excludes `schema.d.ts`, `openapi/` and `admin/tests` from its class scan, so type-only changes do not churn `dist/`.
+
+### Docs checker
+
+`go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` both pass on the current tree (run this session: `ok ... cmd/summer 1.696s`, `docs:build: no problems found`). Pages to update: `docs/backend/admin-controllers.md`, `forms.md`, `lists-and-filters.md`, `relation-manager.md`, `users-and-permissions.md`, `admin-spa.md`, plus `modules/cabana/README.md` (sections: Overview, Features, Admin API routes, Usage, API reference, Configuration, CLI commands, Dependencies, Testing) and `modules/pact/README.md`. Go fences in docs pages must be `src=` references to compiled code; hand-written Go fences are refused.
+
+## PHP Screen Inventory and Gap Map
+
+Legend: **OK** works in cabana today; **PORT** works after a YAML rewrite or plugin code; **FW-D** framework feature already decided (D-09..D-12, D-16); **FW-NEW** framework gap not listed in CONTEXT.md.
+
+### Users list (`controllers/users/config_list.yaml`, `models/user/columns.yaml`, `config_filter.yaml`, `_list_toolbar.htm`)
+
+| PHP element | Status | Notes |
+|-------------|--------|-------|
+| `recordUrl: golem15/user/users/preview/:id` | FW-D (D-11) | `mapWinterUrl` must learn `preview/:id` |
+| `showCheckboxes`, `showSetup`, `recordsPerPage: 20`, search prompt | OK | |
+| `toolbar.buttons: list_toolbar` (partial) | PORT | Rewrite as a list: `[create, delete]` |
+| New user button | OK | built-in `create` |
+| Bulk actions dropdown: delete, activate, deactivate, restore, ban, unban, each with its own confirm text | FW-D (D-09) | Each needs a label and a confirm message key |
+| `listInjectRowClass`: `strike` (trashed), `negative` (banned), `disabled` (not activated); several at once | FW-D (D-12) | Must return a set, not one value; banned needs `user_throttle` |
+| `listExtendQuery: withTrashed` | PORT | `ListExtendQuery` returning `db.Unscoped()` |
+| column `id` (invisible) | FW-NEW (G6) or drop | |
+| column `username` (invisible, searchable) | dropped (D-18) | |
+| column `name` (searchable), `email` (searchable) | OK | |
+| column `surname` (searchable, invisible) | FW-NEW (G6) | Without `invisible`, show it or lose the search |
+| column `created_at` (`type: timetense`) | PORT | Use `datetime`; `models.User` has no `created_at` field yet (the column exists) |
+| column `last_seen` (`type: timetense`) | PORT | New column and field (D-17); use `datetime` |
+| column `is_guest` | dropped (D-03) | |
+| columns `created_ip_address`, `last_ip_address` (searchable, invisible) | FW-NEW (G6) | |
+| filter `groups` (`scope: filterByGroup`, `modelClass`, `nameFrom`) | PORT | `models.User` implements `pact.FilterScope` and `pact.FilterOptions`; the value is one id (PHP takes several) |
+| filter `created_date` (`daterange`, `conditions`) | PORT | `type: daterange` + `column: created_at`; needs a `time.Time` model field |
+| filter `activated` (`switch`, `conditions` pair) | PORT | `type: switch` + `column: is_activated`; no scope needed |
+| event `golem15.user.view.extendListToolbar` | not ported | No listener in the application `[ASSUMED]` |
+| `addJs bulk-actions.js` | not ported | Replaced by D-09 |
+
+### Users preview (`preview.htm`, `_preview_toolbar.htm`, `_hint_*.htm`, `_preview_scoreboard.htm`)
+
+| PHP element | Status | Notes |
+|-------------|--------|-------|
+| Preview screen, form rendered read-only | FW-D (D-11) | |
+| Hint precedence: guest → banned → trashed → not activated (one hint shown) | PORT on top of FW-D | Guest dropped. A `type: partial` field with `context: preview` renders the hint from a controller view model; no new mechanism needed |
+| Toolbar: back to list, Update details | FW-D (D-11) | |
+| Toolbar: Impersonate | dropped (D-01) | |
+| Toolbar: Unsuspend, only when `isSuspended()` | FW-D (D-10) | applicability check |
+| Hint link: Activate manually (`onActivate`) | FW-D (D-10) | record action `activate`, applies when not activated |
+| Hint link: Unban (`onUnban`) | FW-D (D-10) | record action `unban`, applies when banned |
+| Scoreboard: name, email, joined, status, last seen, online | discretion | Could be a second partial; optional |
+| Fields with `context: preview`: `created_ip_address`, `last_ip_address` | OK after FW-D | Already compile |
+| event `extendPreviewToolbar` | not ported | |
+
+### Users form (`models/user/fields.yaml`, `config_form.yaml`, `update.htm`, `Users.php`)
+
+| PHP element | Status | Notes |
+|-------------|--------|-------|
+| top-level `tabs:` / `secondaryTabs:` | PORT | Flatten into `fields:` with a `tab:` per field; no secondary-tab sidebar in the SPA |
+| `name`, `surname` (no `type`) | PORT | Add `type: text` |
+| `email` | PORT | `type: text` |
+| `send_invite` (checkbox, default true, `context: create`) | **FW-NEW (G2)** | Not a model column: boot error today |
+| `block_mail` | dropped (D-21) | |
+| `password@create`, `password@update` (`type: password`) | **FW-NEW (G1, G2)** | No `password` type; `@` is not an identifier; `password` is a protected fill key |
+| `password_confirmation` (`type: password`, `context: [create, update]`) | **FW-NEW (G1, G2)** | Not a model column |
+| `username` | dropped (D-18) | |
+| `groups` (`type: relation`, belongsToMany, `emptyOption`) | PORT + **FW-NEW (G4)** | Works as a relation field with a pivot model for `users_groups`; D-07's locked options and 403 do not exist |
+| `organisation` (`type: relation`, `nameFrom: name`, `emptyOption`) | **FW-NEW (G3)** | `organisation_id` is a protected fill key, so the field compiles read-only |
+| `created_ip_address`, `last_ip_address` (`disabled: true`, `context: preview`) | PORT | Drop `disabled` (unknown key); preview is read-only anyway |
+| `permissions` (`FrontendPermissionEditor`, `mode: radio`, `context: update`) | FW-D (D-16) | Value is a map, column name is a protected fill key; needs its own lift-and-store path |
+| `avatar` (`fileupload`, `mode: image`, 260x260) | PORT | `models.User` needs `AttachRelations()` |
+| `cssClass`, `hidden`, `disabled` keys | PORT | Remove; unknown keys fail boot |
+| `config_form.yaml` `create.redirect: .../preview/:id`, `update.redirectClose: .../preview/:id` | FW-D (D-11) | via `mapWinterUrl` |
+| `formExtendQuery: withTrashed` | PORT | `FormExtendQuery` returning `db.Unscoped()` |
+| `update_onDelete`: `forceDelete()` | PORT (G8) | Built-in delete is a soft delete for a `gorm.DeletedAt` model |
+| `formAfterUpdate` / `formExtendModel` (MailBlocker) | dropped (D-21) | |
+| `formExtendFields` (username) | dropped (D-18) | |
+| Cancel on update goes to preview | FW-D (D-11) | |
+| Model rules `email: required|between:6,255|email|unique:users`, `password: required:create|between:8,255|confirmed` | **FW-NEW (G5)** | Go `User.Rules()` is the register rule set and breaks admin update |
+| `afterCreate`: `send_invite` → `sendInvitation()` (mail `golem15.user::mail.invite`, 72 h link) | PORT | The Go plugin has no `invite` template; port `views/mail/invite.htm` |
+
+### User Groups (`UserGroups.php`, `usergroups/*.yaml`, `models/usergroup/*.yaml`)
+
+| PHP element | Status | Notes |
+|-------------|--------|-------|
+| `requiredPermissions: golem15.users.access_groups` | OK | |
+| list: `name` (searchable), `code`, `created_at`, `id` (invisible) | OK / G6 | |
+| list: `users_count` (`relation: users_count`, `valueFrom: count`, `default: 0`, `sortable: false`) | PORT (G9) | `valueFrom` and `default` are unknown keys; use a read-only model column filled by a subquery |
+| `recordUrl: .../update/:id`, `showSetup`, `showSorting`, no checkboxes | OK | |
+| toolbar: New group | OK | `buttons: [create]` |
+| form: `name` (left), `code` (right, `preset: name`), `description` (textarea, `size: tiny`) | PORT + FW-NEW (G7) | `preset` is an unknown key |
+| form: `permissions` (editor, `mode: checkbox`) | FW-D (D-16) | |
+| `config_form.yaml` `create.title`, `update.title`, `preview.title` | PORT | Remove; unknown keys fail boot |
+| rules `name: required|between:3,64`, `code: required|regex:/^[a-zA-Z0-9_\-]+$/|unique:user_groups` | PORT (G10) | `regex` is not a `lagoon.Validate` rule |
+| no delete button in PHP form or list | discretion | D-06 speaks of deleting a privileged group; the standard form delete button will exist once a form exists |
+
+### Organisations (`Organisations.php`, `organisations/*.yaml`, `models/organisation/*.yaml`)
+
+| PHP element | Status | Notes |
+|-------------|--------|-------|
+| `requiredPermissions: golem15.users.access_users` | OK | |
+| list: `name`, `slug` (searchable), `created_at` (datetime), `id` (invisible) | OK / G6 | |
+| toolbar: New organisation | OK | |
+| form: `name` (required), `slug` (`preset: {field: name, type: slug}`), `description` (textarea, small) | PORT + FW-NEW (G7) | |
+| form: `avatar` (fileupload image 120x120) | PORT | `models.Organisation` needs `MorphName()` and `AttachRelations()` |
+| form: `members` (`type: partial` → `relationRender`) | PORT | `type: relation-manager`, `relation: members`, `context: update` |
+| `config_relation.yaml` `members`: `toolbarButtons: add|remove`, `list: $/.../columns.yaml` | PORT | `link|unlink`; inline `columns`; contract kind `hasMany`, `ForeignKey: "organisation_id"` (nullable, so unlink works) |
+| rules `name: required|max:255`, `slug: required|alpha_dash|unique:...`, `description: nullable|max:5000` | PORT (G10) | `alpha_dash` is not a `lagoon.Validate` rule |
+| `beforeValidate`: slug from name when empty | PORT | `lagoon.HasBeforeValidate` on the model |
+
+### Plugin registration (`Plugin.php`)
+
+Permissions, verbatim `[VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php:164-179]`: `golem15.users.access_users`, `golem15.users.access_groups`, `golem15.users.access_settings`, `golem15.users.impersonate_user`, all with tab `golem15.user::lang.plugin.tab`.
+
+Navigation `[VERIFIED: Plugin.php:186-214]`: main item `user` (label `golem15.user::lang.users.menu_label`, icon `icon-user`, permissions `golem15.users.*`, order `555`) with side menu `users` (`access_users`), `usergroups` (`access_groups`), `organisations` (`access_users`).
+
+## Framework Gaps Beyond CONTEXT.md
+
+These block locked decisions and are in scope under the roadmap rule "`summercms.go` only if the admin pipeline is missing a feature the screens need". Each recommendation is a design choice `[ASSUMED]` until the user confirms it.
+
+| # | Gap | Blocks | Evidence | Recommended answer |
+|---|-----|--------|----------|--------------------|
+| G1 | No `password` field type | D-19 | `formFieldTypes` list above | Add `type: password`: masked input, never projected into a record response, never a fill key |
+| G2 | Hooks cannot see non-column form values (`password`, `password_confirmation`, `send_invite`) | D-19 | Hook signatures take `(ctx, model)`; `BindWritableFields` fails boot for a scalar field that is not a column | "Form virtual fields": a controller lists field names exempt from column binding; their submitted scalar values (filtered by `context`) reach hooks through a context accessor next to `TxFromContext` |
+| G3 | `organisation` picker is read-only | D-22 | `relation_field.go:198`, `crud.go:836` | An explicit opt-in on `FieldRelationContract` that declares a protected foreign key writable for this field |
+| G4 | No locked relation options and no 403 from plugin code | D-04, D-06, D-07 | `RelationOption` has two fields; `writeCRUDError` has no forbidden branch | An exported `cabana.ForbiddenError` mapped to 403 everywhere `writeCRUDError`/`runAction` map errors; an optional controller interface returning the related ids the current principal may not change, which the framework marks `locked` in options and labels and enforces in `syncBelongsToMany` (a change in the locked subset is 403 and rolls back) |
+| G5 | Admin save validates against the model's register rules | D-19 | `mergedRules` reads `model.Rules()`; `valuesForRules` reads model columns only, so `confirmed` compares the stored hash with a missing `password_confirmation` | Either an optional controller interface that supplies the rule set per operation, or an admin record type wrapping `models.User` with its own `Rules()`; see Open Question 2 |
+| G6 | No `invisible` list column | PHP parity of search | `columnDocument` keys | Add `invisible: true`: the column is searchable and sortable server-side but not rendered |
+| G7 | No `preset` | D-22 | `formFieldKeys` | Add `preset` (string or `{field, type}`) for text fields: the SPA fills the field from the source while it is untouched, on create only. Keep the server default in `BeforeValidate` |
+| G8 | Built-in delete soft-deletes a `DeletedAt` model | D-13 | `deleteRecord` calls `tx.Delete(model)` | No framework change: `FormAfterDelete` on the Users controller hard-deletes with `Unscoped()` inside the same transaction |
+
+Gaps solved in the plugin with no framework change:
+
+| # | Gap | Answer |
+|---|-----|--------|
+| G9 | `users_count` | A read-only field on the group model with a `users_count` column tag, filled by a `ListExtendQuery` select subquery; `sortable: false` `[ASSUMED: GORM `->` read-only fields and Count over a custom select behave as expected; cover with a test]` |
+| G10 | `regex`, `alpha_dash` | Keep them out of `Rules()` and check them in `FormBeforeCreate`/`FormBeforeUpdate`, returning `*cabana.ValidationError` (verified to pass through as 422). `lagoon.Validate` supports exactly: `nullable`, `required`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes` `[VERIFIED: modules/lagoon/validate.go:57-112]`; any other token returns an error that the save turns into a 500 |
+| G11 | `timetense` | Use `datetime` |
+| G12 | `name@context` field names | One `password` field with `context: [create, update]` and one label |
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Admin SPA (ListView / FormView / new preview mode)
+ | selected ids | record id + action | form body (scalars, relation ids,
+ v v v permission map, virtual fields)
+POST .../bulk/{action} POST .../{id}/actions/{action} POST|PUT .../{controller}[/{id}]
+ | | |
+ +----------- backend guard -> requireAjax -> protect (controller permissions) -----------+
+ | | |
+ declared in list YAML? declared in form YAML? operationDeclared
+ allowAction (own perms) allowAction (own perms) |
+ | | v
+ lagoon.Transaction lagoon.Transaction lagoon.Transaction
+ lockScoped(ListExtendQuery) loadRecord(FormExtendQuery) loadRecord -> Fill -> Validate
+ | 0 rows: no-op | not found: 404 -> FormBefore* (plugin: password hash,
+ | partial: 409 | Applies == false: 409 privileged-code check)
+ v v -> relation scope check
+ plugin Run(ctx, records) plugin Run(ctx, record) -> locked-id guard (403) <-- T-12-18
+ | | -> Save -> syncBelongsToMany
+ +--> users / user_throttle / users_groups (Postgres) <---+ -> permission map store
+ -> FormAfter* (plugin: invite mail,
+GET .../{controller} -> ExecuteList -> rows + row state force delete cleanup)
+GET .../{id} -> record + labels + offered record actions
+GET .../schema/list -> columns, filters, bulk actions (filtered by permission)
+GET .../schema/form -> fields (permission options injected per request), preview flag
+```
+
+### Recommended Project Structure
+
+```
+summercms.go/
+├── modules/pact/capabilities.go # new action and row-state contracts
+├── modules/cabana/
+│ ├── actions.go # bulk and record action handlers
+│ ├── list_schema.go, schema_types.go # bulkActions, invisible, row state
+│ ├── form_schema.go, crud.go # preview, password, virtual fields, preset, permissioneditor
+│ ├── field_permission.go # new: permissioneditor compile, lift, store, project
+│ ├── relation_field.go # locked options, writable protected key
+│ ├── http.go, admin_openapi.go # routes and swag stubs
+│ └── testdata/ # neutral fixture plugin (acme)
+├── admin/src/
+│ ├── components/form/fields/PermissionEditorField.vue, PasswordField.vue
+│ ├── components/list/ (bulk action menu, row state classes)
+│ └── views/FormView.vue (preview mode) + app/router.ts + app/winterUrl.ts
+└── docs/backend/*.md, modules/cabana/README.md, modules/pact/README.md
+
+sm-user-plugin/ (../fonoteka.go/plugins/golem15/user)
+├── plugin.go # HasAdminControllers, AdminAssets, HasPermissions, HasNavigation
+├── controllers/
+│ ├── users_admin_controller.go, usergroups_admin_controller.go, organisations_admin_controller.go
+│ ├── users/ (config_list.yaml, config_form.yaml, config_filter.yaml, _hint.htm)
+│ ├── usergroups/ (config_list.yaml, config_form.yaml)
+│ └── organisations/ (config_list.yaml, config_form.yaml, config_relation.yaml)
+├── models/ (user/, usergroup/, organisation/ YAML; frontend_permission.go; users_group.go pivot)
+├── classes/ (permissions.go resolver; privileged.go; admin_actions.go)
+├── updates/ (three additive migrations)
+├── views/mail/invite.htm, invite-en.htm
+├── lang/{en,pl}/lang.yaml
+└── config/config.yaml (privileged group codes)
+```
+
+### Pattern 1: An action that takes ids resolves them through the list scope first
+**What:** Copy `BulkDelete`'s shape: normalize ids, open `lagoon.Transaction`, `lockScoped`, refuse a partial match with 409, then call the plugin with loaded records, never with raw ids.
+**When to use:** D-09.
+**Example:** `[VERIFIED: modules/cabana/crud.go:212-242]`
+```go
+err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
+ ctx = withTx(ctx, tx)
+ proto, err := newWritableModel(cc)
+ if err != nil {
+ return err
+ }
+ rows, err := lockScoped(ctx, tx, cc, proto, ids)
+ if err != nil {
+ return err
+ }
+ if len(rows) == 0 {
+ result.Deleted = 0
+ return nil
+ }
+ if len(rows) != len(ids) {
+ return partialSelection{}
+ }
+ // per-row work
+ return nil
+})
+```
+Note: PHP skips ids it cannot find; cabana's existing rule is "mixed present and absent is a 409 and rolls back" (Phase 9 decision). Keep cabana's rule for the new bulk actions so the behaviour matches bulk delete.
+
+### Pattern 2: Plugin hooks read the write's transaction from the context
+**What:** `cabana.TxFromContext(ctx)` inside `FormBefore*`/`FormAfter*`, relation hooks and scopes.
+**When to use:** Every hook in the three controllers that touches `user_throttle`, `users_groups` or attachments.
+**Example:** see `collectionsAdminController.FormBeforeCreate` in `../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:106-138` (uses a `requestDB(ctx, c.db)` helper and returns `&cabana.ValidationError{Details: ...}` for a 422).
+
+### Pattern 3: Schema entries are filtered per principal at request time
+**What:** The cached schema holds everything; the handler drops what the admin may not run (`listSchema` filters `ToolbarActions`, `formSchema` filters widgets) into a new slice so the cache is never mutated.
+**When to use:** Bulk actions in the list schema, record actions on the record response, permission options in the form schema.
+**Example:** `[VERIFIED: modules/cabana/http.go:714-721]`
+```go
+principal, _ := bouncer.User(r.Context())
+allowed := make([]ToolbarAction, 0, len(view.ToolbarActions))
+for _, action := range view.ToolbarActions {
+ if registered, ok := cc.Actions[action.Name]; ok && Allows(principal, registered.Permissions) {
+ allowed = append(allowed, action)
+ }
+}
+view.ToolbarActions = allowed
+```
+
+### Pattern 4: Model-owned metadata, never guessed
+**What:** Pivot tables, foreign keys and label columns come from controller contracts (`FieldRelationContract`, `RelationContract`).
+**When to use:** `groups` (pivot model for `users_groups` with columns `user_id`, `user_group_id`), `organisation` (belongsTo, `ForeignKey: "organisation_id"`), `members` (hasMany, `ForeignKey: "organisation_id"`).
+
+### Recommended contract names (Claude's discretion; all `[ASSUMED]` until confirmed)
+
+| Feature | pact / cabana | YAML | Route |
+|---------|---------------|------|-------|
+| Bulk actions | `pact.AdminBulkAction{Name, Label, Confirm, Permissions, Run}`, `pact.HasAdminBulkActions`; input carries the loaded records | `config_list.yaml` `bulkActions: [activate, deactivate, restore, ban, unban]` (requires `showCheckboxes: true`; built-in `delete` stays on `toolbar.buttons`) | `POST /{vendor}/{plugin}/{controller}/bulk/{action}`, body `AdminIDsRequest` |
+| Record actions | `pact.AdminRecordAction{Name, Label, Confirm, Permissions, Applies, Run}`, `pact.HasAdminRecordActions` | `config_form.yaml` `recordActions: [activate, unban, unsuspend]` | `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}`, body `{}` |
+| Preview | form schema carries a preview flag; record response lists offered actions | `config_form.yaml` `preview:` block (redirect targets); fields use `context: preview` | SPA route `/:vendor/:plugin/:controller/:id(\d+)/preview`; no new API read route |
+| Row state | `pact.ListRowStates` hook, batch form (all page rows in one call) | none | list rows carry the state set |
+| Permission editor | controller supplies options `{code, label, tab, comment}` | `type: permissioneditor`, `mode: radio` or `checkbox` | options injected into the form schema per request |
+
+All action names share the single controller namespace with toolbar and widget actions; `create` and `delete` stay reserved. A bulk or record action a YAML file names but the controller does not register is a boot error, as for `toolbar.buttons`.
+
+Row state must be a **batch** hook: the Users implementation needs one `user_throttle` query for the whole page, and a per-row hook would be an N+1. Recommended set: `deleted`, `negative`, `disabled` (the three D-12 names). A row can hold more than one (PHP joins up to three classes). Values outside the set are dropped and logged, never sent.
+
+### Anti-Patterns to Avoid
+- **Passing ids to plugin code:** breaks the 10.1 D-12 property. The plugin receives loaded records.
+- **Checking privileged groups only in the SPA:** D-04 is a server rule; the locked UI is a courtesy.
+- **Adding `CreatedAt`/`UpdatedAt` as ordinary fields to `models.User`:** GORM would start stamping `updated_at` on every user write and the fields could leak into serialization. Add them read-only (`->`) with `json:"-"`.
+- **Copying PHP YAML verbatim:** `tabs:`, `secondaryTabs:`, `cssClass`, `hidden`, `disabled`, `preset`, `invisible`, `valueFrom`, `default` (on a column), `title` under `create`/`update`, `preview:`, a scalar `toolbar.buttons`, `conditions:` and `add|remove` are each a boot error today.
+- **A groups relation manager in addition to the groups field:** it would add link and unlink write paths to `users_groups`, and unlink has no hook to guard. Keep one writer: the form field.
+- **Naming the application in framework fixtures, READMEs or docs.**
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Password hashing | A new bcrypt call | `bouncer.HashPassword(cost, password)` with `golem15.user.password.bcrypt_cost` (as `Login` does) | Same cost and rehash rules as the API |
+| Activation | A direct `is_activated = true` update | The plugin's existing activation helpers (`IssueActivationCode`, `VerifyActivationCode`) or a small shared function next to them | PHP `attemptActivation` also clears the code, stamps `activated_at` and restores a trashed user |
+| Avatar storage | A second attachment path | `attach.HasRelations` on the model plus cabana's file routes | Same `system_files` row as the API |
+| Attachment cleanup on force delete | Manual `DELETE FROM system_files` | `attach.DeleteForOwner(tx, owner, ownerID, afterCommit)` + `attach.DeleteKeys` (as `deleteAvatar` does, `api_controller.go:1205-1230`) | Blob deletion must wait for the commit |
+| Id-list scoping | A new `WHERE id IN` | `lockScoped` | Applies the list scope and the lock |
+| Permission checks | String comparison | `cabana.Allows(principal, []string{code})` | Wildcards and superuser handling |
+| Tabs, radio groups, checkboxes in the SPA | Custom components | `reka-ui` primitives already used by the form | Accessibility and no new package |
+| TS types | Hand-written interfaces | `scripts/check-admin-openapi.sh` | The drift gate fails otherwise |
+
+**Key insight:** every piece of the three screens already has a framework home; the work is adding seams, not parallel code paths.
+
+## Current State of `sm-user-plugin`
+
+`[VERIFIED: files read under ../fonoteka.go/plugins/golem15/user this session]`
+
+- Module `git.golem15.com/golem15/sm-user-plugin`, requires `git.golem15.com/golem15/summercms v0.0.0` with `replace => ../../../../summercms.go`. The application's `go.mod` and every plugin use the same local replace, so "builds on the v0.1.3 tag" means the tag exists on the framework commit the plugin was developed against; no `go.mod` version changes.
+- The submodule's `master` is **5 commits ahead of `origin/master`** (unpushed). The fonoteka.go submodule pointer bump at the end of this phase depends on a push.
+- `plugin.go` implements `HasConfig`, `HasMigrations`, `HasMiddleware`, `HasModels`, `HasRoutes`, `HasMailTemplates`, `HasLang`, `HasCommands`, `surf.BucketProvider`. It embeds `config`, `views/mail`, `lang`. It has no `AdminFS`, admin controllers, permissions or navigation.
+- `models.User`: no `CreatedAt`, `UpdatedAt`, `LastSeen`, `LastLogin` or `Permissions` field. `Groups []UserGroup` with `many2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_id` and `json:"-"`. `DeletedAt gorm.DeletedAt`. `MorphName()` returns `Golem15\User\Models\User`. `Rules()` is the register rule set (`password: required|between:8,255|confirmed`).
+- `models.UserGroup`: `Code`, `Description`, `Permissions` are `*string`; timestamps are `*time.Time`. No `Fillable()`, no `Rules()`.
+- `models.Organisation`: no `Fillable()`, `Rules()`, `MorphName()`, `Members` field. cabana requires `lagoon.HasFillable` on a writable model (`newWritableModel`).
+- `models.Throttle`: `is_banned`, `is_suspended`, `suspended_at`; **no `banned_at` or `last_attempt_at`** (the parity allow-list records this).
+- There is no pivot model struct for `users_groups`; the belongsToMany field contract needs one (columns `user_id`, `user_group_id`, composite primary key named `user_group`).
+- Migrations: `202609170001_create_users`, `10_organisations`, `202609220005_extend_users`, `202609220006_create_user_throttle`, `202609220007_create_jwt_blacklist`, `202610020001_create_user_groups`. `users.created_at`/`updated_at` exist as columns; `last_seen`, `permissions`, `last_login` do not.
+- `user_throttle.user_id` is `REFERENCES users(id)` **without `ON DELETE`** `[VERIFIED: updates/202609220006_create_user_throttle.go:15]`. The application's tables reference `users(id)` with `ON DELETE CASCADE` (or `SET NULL` for `accepted_by`) `[VERIFIED: grep of ../fonoteka.go/plugins/golem15/fonoteka/updates]`.
+- Auth path: `controllers.Login` (`api_controller.go:47-124`) restores a trashed user and resets the throttle, but writes no `last_login` or `last_seen`. `controllers.Refresh` (`175-201`) verifies and rotates the token through `bouncer.Refresh` and **never loads the user row**; a `last_seen` write on refresh needs the subject id from the token.
+- Suspension semantics in Go: suspended means `is_suspended` and `suspended_at` within `golem15.user.throttle.suspension_minutes` (`throttleBlocked`, `classes/throttle.go`). PHP `isSuspended()` also lifts an expired suspension as a side effect; the Go `Applies` check must be a pure read.
+- Mail templates: `activate`, `restore`, `reactivate` (each with `-en`). No `invite`.
+- Config namespace `golem15.user.*` from `config/config.yaml`: `jwt`, `activation`, `registration`, `throttle`, `password`. `compass.Config` has `String`, `Int`, `Bool`, `Has`, `Lookup`, `LoadSection`; a string list is read with `LoadSection` or `Lookup`.
+
+### Parity schema allow-list (`../fonoteka.go/parity/schema_diff_test.go`)
+
+- `users` is special-cased: its columns are not diffed; the test only requires that PHP has **more** columns than Go (`phpN <= goN` fails). The PHP snapshot's `users` table already has `permissions text` and `last_seen timestamp(0)`, so adding both keeps the gap and needs **no** new entry.
+- `user_groups`, `users_groups`, `user_throttle` are already allow-listed as extra Go tables.
+- `golem15_user_frontend_permissions` is not in the snapshot (`grep "CREATE TABLE"` shows only fonoteka tables, `golem15_user_organisations`, `system_files`, `users`), so it needs **one new `allowedDiffs` entry**, worded like the existing `user_groups` one.
+- `golem15_user_organisations` is a shared table and is column-diffed: do not add columns to it.
+
+## T-12-18: Every `users_groups` Write Path
+
+Threat as recorded `[VERIFIED: .planning/phases/12-.../12-01-PLAN.md:343]`: "No route writes users_groups; Groups is never serialized; the site-admin predicate reads group codes server-side only". The consumer is `HasGroupCode(ctx, db, user.ID, siteAdminGroup)` in `../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go:31`.
+
+Today no production code writes `users_groups` `[VERIFIED: grep across ../fonoteka.go; only test seeds insert rows]`. After this phase:
+
+| # | Path | Writes | Guard needed |
+|---|------|--------|--------------|
+| 1 | User form `groups` field (create and update) → `syncBelongsToMany` | Replaces all of a user's pivot rows | Locked-id guard: the privileged subset before and after must be equal unless the principal holds the extra permission; else 403, whole save rolled back (D-07). Applies on **create** too (an unprivileged admin must not create a user already in `admin`) |
+| 2 | User force delete (form button, bulk `delete`) | Removes the user's pivot rows | None beyond `access_users`: removing a user cannot grant privilege |
+| 3 | User Groups form: create or update with a privileged `code` (either direction) | No pivot write, but every member of a renamed group gains or loses the privilege | Extra permission (D-06), checked in `FormBeforeCreate`/`FormBeforeUpdate` against the stored code and the submitted code |
+| 4 | User Groups delete of a privileged group | Removes pivot rows of that group | Extra permission (D-06), in `FormBeforeDelete`; also delete the pivot rows (no FK cascade on `users_groups`) |
+| 5 | Bulk and record actions (D-14) | None of the six bulk or three record actions touches groups | Keep it that way; a unit test asserts the pivot is unchanged after each action |
+| 6 | Organisation `members` relation manager | `users.organisation_id` only | Not a groups path |
+| 7 | Config change to the privileged list | Changes which groups are privileged | Operational, outside the admin UI |
+| 8 | Bulk `restore` / `activate` of a trashed site admin | No pivot write, but re-enables an account whose membership survived the soft delete | Accepted: PHP behaves the same; note it in the security review |
+
+Privilege check helper: compare group **codes** (case-sensitive, as `HasGroupCode` does) against the configured list; a group with a NULL code is never privileged. The privileged list must be read per request, not cached at boot, so an overlay change takes effect on reload.
+
+The extra permission (recommended code `golem15.users.manage_privileged_groups`, tab `golem15.user::lang.plugin.tab`) must be registered through `pact.HasPermissions` or boot fails when an action or hook names it in a permission list validated by `Registry.validatePermissions`.
+
+## Common Pitfalls
+
+### Pitfall 1: Admin update of a user fails validation on `password`
+**What goes wrong:** Every save returns 422 "password confirmation".
+**Why it happens:** `mergedRules` uses `User.Rules()`; `confirmed` compares the stored hash with `values["password_confirmation"]`, which is absent because `valuesForRules` reads model columns only.
+**How to avoid:** Resolve G5 before writing the Users controller.
+**Warning signs:** A smoke test that updates only `name` returns 422.
+
+### Pitfall 2: An unknown rule token becomes a 500
+**What goes wrong:** `regex` or `alpha_dash` in `Rules()` makes every save of that model answer the opaque 500.
+**Why it happens:** `lagoon.Validate` returns an error for an unrecognized token and `save` maps it to `CapabilityError`.
+**How to avoid:** Keep `Rules()` to the supported tokens; check the rest in hooks.
+
+### Pitfall 3: Force delete fails on the throttle foreign key
+**What goes wrong:** Hard-deleting a user who ever failed a login returns 500.
+**Why it happens:** `user_throttle.user_id REFERENCES users(id)` has no `ON DELETE`.
+**How to avoid:** Delete `user_throttle` rows, `users_groups` rows and attachments in the same transaction before the `Unscoped().Delete`. Do not edit the shipped migration (frozen); a new additive migration changing the constraint is possible but not needed.
+
+### Pitfall 4: Email uniqueness ignores trashed users
+**What goes wrong:** Creating a user with the email of a deactivated user passes validation, then hits the `users.email` UNIQUE constraint and answers 500.
+**Why it happens:** `uniqueOK` adds `deleted_at IS NULL` when the table has that column (`validate.go:370-372`); Laravel's `unique:users` counts trashed rows.
+**How to avoid:** An `Unscoped` pre-check in `FormBeforeCreate`/`FormBeforeUpdate` returning a 422 on `email`.
+
+### Pitfall 5: Stored permission JSON shapes differ between PHP writers
+**What goes wrong:** A strict JSON-object decoder fails on imported rows.
+**Why it happens:** PHP `setPermissionsAttribute` writes `''` for an empty user set and drops zero values `[VERIFIED: vendor/winter/storm/src/Auth/Models/User.php:560-579]`; group permissions are a `jsonable` attribute whose empty form is not an object `[ASSUMED: "[]" or NULL]`; values may arrive as strings.
+**How to avoid:** A plugin-owned `PermissionSet` type with a tolerant `Scan` (NULL, `''`, `[]`, numeric strings) and a writer that emits an object of integers, with user values limited to `-1` and `1` (zero removed) and group values to `1`. `lagoon.Jsonable[map[string]int]` would reject `[]`.
+
+### Pitfall 6: The merged-permission resolver is not "deny wins"
+**What goes wrong:** A port that treats `-1` in any group as final diverges from PHP.
+**Why it happens:** PHP merges groups with "most positive wins", then overlays user values with `array_merge` (user overrides group) `[VERIFIED: models/User.php:257-275]`; `hasPermission` then needs the value to be exactly `1` and supports trailing and leading `*` wildcards on both sides.
+**How to avoid:** Port `getMergedPermissions` and `hasPermission` line by line; `cabana.granted` (`contracts.go:153-185`) is already a port of the wildcard logic for a boolean map and is a useful reference.
+
+### Pitfall 7: A list hook has no transaction
+**What goes wrong:** A row-state hook that calls `TxFromContext` gets `false`.
+**Why it happens:** `ExecuteList` runs on the pool, not in a transaction.
+**How to avoid:** Pass the list's `*gorm.DB` handle to the row-state hook explicitly.
+
+### Pitfall 8: Stale `dist/` or OpenAPI output
+**What goes wrong:** The phase gate fails on drift.
+**Why it happens:** `dist/` and `schema.d.ts` are committed build products.
+**How to avoid:** Every task that touches `admin/src` or the swag annotations regenerates and commits them in the same commit.
+
+### Pitfall 9: New routes missing from the contract inventory
+**What goes wrong:** `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix` or `TestPhase10OpenAPIConformance` fail.
+**Why it happens:** They compare the mounted route table, the permission matrix and the OpenAPI document.
+**How to avoid:** Add the route, its swag stub and its matrix row together.
+
+### Pitfall 10: `permissions` and `mode` collide with existing rules
+**What goes wrong:** A `permissioneditor` field named `permissions` is silently unbound, or `mode` is refused.
+**Why it happens:** `permissions` is a protected fill key for scalar binding and `mode` is only valid on fileupload and datepicker.
+**How to avoid:** Give the new type its own lift, store and project path (like relation fields) and extend the `mode` rule.
+
+### Pitfall 11: `last_seen` leaking into the user payload
+**What goes wrong:** The Nuxt contract changes.
+**Why it happens:** A new exported field on `models.User` without `json:"-"`, or a payload builder change.
+**How to avoid:** `json:"-"` on the field; the payload is built by `apiArray` from an explicit map. No parity fixture contains `last_seen` `[VERIFIED: grep of ../fonoteka.go/parity/testdata matches only the schema snapshot]`, so the parity corpus will catch a leak.
+
+### Pitfall 12: The invitation link shape
+**What goes wrong:** An invited user cannot activate.
+**Why it happens:** PHP builds a signed route URL valid for 72 hours; the Go plugin's `activationLink` builds `{base}/activate?code={id}!{code}` and the code TTL is `golem15.user.activation.activation_ttl_hours` (72).
+**How to avoid:** Reuse `IssueActivationCode` + `activationLink` for the invite mail; do not port Laravel signed URLs.
+
+### Pitfall 13: Submodule and tag ordering
+**What goes wrong:** The application pointer bump references an unpushed plugin commit, or the tag is cut before `dist/` is rebuilt.
+**How to avoid:** Order: framework commits green → gate → tag v0.1.3 → plugin commits → push plugin → bump the submodule pointer in fonoteka.go.
+
+## Code Examples
+
+### Registering permissions and navigation in a plugin
+```go
+// Source: modules/pact/capabilities.go:269-297 (types read this session)
+func (p *Plugin) Permissions() []pact.Permission {
+ return []pact.Permission{
+ {Code: "golem15.users.access_users", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_users"},
+ {Code: "golem15.users.access_groups", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_groups"},
+ {Code: "golem15.users.access_settings", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.access_settings"},
+ {Code: "golem15.users.impersonate_user", Tab: "golem15.user::lang.plugin.tab", Label: "golem15.user::lang.plugin.impersonate_user"},
+ }
+}
+```
+The four codes, the tab key and the label keys are the PHP values quoted in the inventory above. The fifth (privileged groups) code is Claude's discretion.
+
+### A belongsTo and a belongsToMany field contract
+```go
+// Source: shape from ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:66-74
+// and modules/cabana/relation_field.go:22-42
+func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
+ return []cabana.FieldRelationContract{
+ {Field: "organisation", Kind: "belongsTo", NewRelated: func() any { return &models.Organisation{} }, ForeignKey: "organisation_id", LabelColumn: "name"},
+ {Field: "groups", Kind: "belongsToMany", NewRelated: func() any { return &models.UserGroup{} },
+ NewPivot: func() any { return &models.UsersGroup{} }, ParentForeignKey: "user_id", RelatedForeignKey: "user_group_id", LabelColumn: "name"},
+ }
+}
+```
+`models.UsersGroup` does not exist yet `[ASSUMED name]`; the column names `user_id` and `user_group_id` are from `updates/202610020001_create_user_groups.go`. `organisation` stays read-only until G3 lands.
+
+### A hook that answers 422
+```go
+// Source: ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go:134-136
+return &cabana.ValidationError{Details: map[string]any{"owner": []string{"The backend email must identify exactly one active user."}}}
+```
+
+### Flattened list YAML in the Go dialect
+```yaml
+# Source: ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml
+list: ~/plugins/golem15/fonoteka/models/collection/columns.yaml
+modelClass: Golem15\Fonoteka\Models\Collection
+recordUrl: golem15/fonoteka/collections/update/:id
+recordsPerPage: 20
+showCheckboxes: true
+toolbar:
+ buttons: [create, delete]
+ search:
+ prompt: backend::lang.list.search_prompt
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Winter toolbar partial with `data-request="onBulkAction"` | Declared bulk actions over a REST route | This phase (D-09) | REQUIREMENTS "Out of Scope" already says bulk actions become REST endpoints |
+| `listInjectRowClass` free CSS classes | Fixed state set styled by design tokens | This phase (D-12) | No plugin CSS needed for row state |
+| PHP form widget class name in YAML | Built-in `type: permissioneditor` | This phase (D-16) | |
+| Toolbar actions without ids (10.1 D-12) | Bulk actions with ids resolved through the list scope | This phase | The "no unscoped lookup" property is kept by construction |
+
+**Deprecated/outdated:** nothing in the stack. `v0.1.2` is the latest framework tag (`git tag` shows `v0.1.1`, `v0.1.2`); this phase cuts `v0.1.3`.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The new POST routes (`.../bulk/{action}`, `.../{id}/actions/{action}`) do not conflict in surf/ServeMux | Routes | A different path shape is needed; found at the first router test |
+| A2 | Form virtual fields via a controller-declared list and a context accessor is the right seam for G2 | Framework Gaps | Contract growth the user did not approve; costly to rename later |
+| A3 | A writable opt-in on `FieldRelationContract` is the right answer for G3 | Framework Gaps | Same |
+| A4 | Locked relation ids plus `cabana.ForbiddenError` is the right answer for G4 | Framework Gaps | Same; D-07 cannot be met without some equivalent |
+| A5 | `preset` and `invisible` are wanted in the framework rather than dropped | Framework Gaps | Larger v0.1.3 than the user expects |
+| A6 | A GORM read-only field plus a select subquery gives `users_count` without breaking `Count` or `Save` | G9 | Needs a different mechanism (a framework computed column) |
+| A7 | An embedded wrapper struct works with `lagoon.Fill`, GORM hooks and `modelFields` (only relevant if G5 is solved that way) | G5 | Boot or save failures; fall back to a rules interface |
+| A8 | PHP group `permissions` empty form is `[]` or NULL | Pitfall 5 | The tolerant scanner covers both, so low |
+| A9 | Winter detaches belongsToMany pivot rows when a user is hard-deleted | Force delete cleanup | Orphan pivot rows if not cleaned; the recommended cleanup deletes them regardless |
+| A10 | No application listener uses PHP's `extendListToolbar` / `extendPreviewToolbar` view events | Inventory | A feature silently disappears at cutover |
+| A11 | The recommended interface, YAML key and route names | Contract names table | Naming only; cheap before release, costly after v0.1.3 |
+| A12 | Row states limited to `deleted`, `negative`, `disabled` | Row state | A later plugin needs more; adding a state is additive |
+
+## Open Questions
+
+1. **Are the extra framework seams (G1 to G7) accepted into v0.1.3?**
+ - What we know: D-07, D-19 and D-22 cannot be met with the five decided features alone; evidence is quoted above.
+ - What's unclear: whether the user wants all of them in this phase or prefers to relax a decision (for example an organisation field that stays read-only on the user form and is managed only from the organisation's members tab).
+ - Recommendation: present G1 to G7 at the plan-count checkpoint as one table with "needed for D-xx" and let the user accept or cut each.
+
+2. **How should the admin form's validation rules be supplied (G5)?**
+ - What we know: `User.Rules()` is the register contract and cannot change.
+ - What's unclear: framework rules interface on the controller versus an admin-only wrapper record type in the plugin.
+ - Recommendation: the controller interface. It is a few lines in `mergedRules`, is useful to every plugin whose API and admin rules differ, and avoids the embedded-struct risks in A7.
+
+3. **Does the User Groups screen get a delete button?**
+ - What we know: PHP's group form and list have no delete; D-06 names "deleting a privileged group".
+ - Recommendation: keep the standard form delete (any cabana form has one), guard it per D-06, and clean the pivot rows.
+
+4. **`last_seen` write policy.**
+ - What we know: PHP only touches it from the CMS session component, at most once per five minutes; the JWT API never does. Go refresh does not load the user.
+ - Recommendation: write on login and on refresh, as one `UPDATE users SET last_seen = now() WHERE id = ? AND (last_seen IS NULL OR last_seen < now() - interval '5 minutes')`, errors logged and ignored so auth never fails on it.
+
+5. **Admin-created users: activated or not?**
+ - What we know: in PHP a backend-created user is not activated; with `send_invite` the mail carries a link, without it the admin activates manually from the preview hint.
+ - Recommendation: same in Go; `is_activated` stays a protected key and only the `activate` actions set it.
+
+6. **Does bulk `activate` on an already activated user fail the whole batch?**
+ - What we know: PHP `attemptActivation` throws "User is already active!" mid-loop, leaving earlier rows changed.
+ - Recommendation: skip already-active users and report the affected count; record it as a deliberate deviation (the admin API has no PHP contract to match).
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go | both repos | ✓ | go1.27.0 | — |
+| Node.js | admin SPA build and tests | ✓ | v22.23.2 (engines `>=22.6`) | — |
+| npm | admin SPA | ✓ | 12.0.2 | — |
+| Docker | testcontainers Postgres in Go tests | ✓ | client 29.7.2; the plugin's `updates` test package started its harness in 4.9 s this session | — |
+| swag | OpenAPI generation | ✓ via `go run github.com/swaggo/swag/cmd/swag@v1.16.6` | v1.16.6 | — |
+| PHP reference tree | read-only inventory | ✓ | `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user` | — |
+| graphify knowledge graph | optional research context | ✗ (disabled) | — | Direct code reading (done) |
+
+**Missing dependencies with no fallback:** none.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework (Go) | standard `testing` + testcontainers Postgres (cabana and plugin harnesses already exist) |
+| Framework (SPA) | vitest 3.2.7 + @vue/test-utils 2.4.11 + happy-dom |
+| Config file | `admin/vitest.config.ts`; Go needs none |
+| Quick run command (framework) | `go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/cabana/... ./modules/pact/... -count=1` |
+| Quick run command (plugin) | `go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` |
+| Quick run command (SPA) | `npm --prefix admin run typecheck && npm --prefix admin test` |
+| Full suite (framework) | `go vet ./... && go test ./... -count=1` |
+| Full suite (application) | `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` |
+| Docs | `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` |
+| Generated artefacts | `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` |
+
+Resolved this session (all from the `summercms.go` root): `go vet ./modules/cabana/ ./modules/pact/` (clean), `go test ./modules/cabana/... ./modules/pact/... -run XXX_none` (packages compile), `go test ./cmd/summer -run TestDocsTree -count=1` (`ok`), `go run ./cmd/summer docs:build --check` (`no problems found`), `go -C ../fonoteka.go test ./plugins/golem15/user/... -run XXX_none` (five packages resolve; `console`, `controllers`, `models` have no test files today), `go -C ../fonoteka.go vet ./plugins/golem15/user/...` (clean). Not run this session: the full suites, `npm --prefix admin test`, the two `scripts/check-admin-*.sh` gates, and `go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot` (the test exists at `parity/schema_diff_test.go:70`).
+
+### Success Criteria → Test Map
+The phase has no requirement IDs; success criteria (SC) and decisions are the units.
+
+| Unit | Behavior | Test Type | Automated Command | File Exists? |
+|------|----------|-----------|-------------------|-------------|
+| D-09 | Bulk action: ids outside the list scope never reach `Run`; partial selection 409; undeclared or unregistered action refused; own permission enforced; one transaction, rollback on error | integration (cabana fixture) | `go test ./modules/cabana/... -run 'TestBulkAction' -count=1` | ❌ Wave 0 |
+| D-09 | A YAML `bulkActions` name the controller does not register fails boot | unit | `go test ./modules/cabana/... -run 'TestListSchemaBulkActions' -count=1` | ❌ Wave 0 |
+| D-10 | Record action: form scope, 404 for out-of-scope, `Applies` false refused, own permission, offered list filtered | integration | `go test ./modules/cabana/... -run 'TestRecordAction' -count=1` | ❌ Wave 0 |
+| D-11 | `context: preview` field never writable; preview redirects map; record response lists actions | unit + SPA | `go test ./modules/cabana/... -run 'TestPreview'`; `npm --prefix admin test -- winterUrl FormView` | ❌ Wave 0 |
+| D-12 | Row state from the fixed set; unknown value dropped; batch hook called once per page | integration + SPA | `go test ./modules/cabana/... -run 'TestRowState'`; `npm --prefix admin test -- DataTable` | ❌ Wave 0 |
+| D-16 | `permissioneditor`: unknown code 422, value outside mode's set 422, stored JSON shape, projection on show | integration + SPA | `go test ./modules/cabana/... -run 'TestPermissionEditor'`; `npm --prefix admin test -- PermissionEditorField` | ❌ Wave 0 |
+| contract | New routes in the inventory, permission matrix and OpenAPI document | contract | `go test ./modules/cabana/... -run 'TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance' -count=1` | ✅ (extend) |
+| SC-1 | Three controllers boot; list, filters, form schemas served; navigation and permissions gate them | integration (plugin) | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdmin(Users|Groups|Organisations)' -count=1` | ❌ Wave 0 |
+| SC-2 | Groups field sync; organisation members link and unlink set and clear `organisation_id` | integration (plugin) | `... -run 'TestAdminUserGroupsField|TestAdminOrganisationMembers'` | ❌ Wave 0 |
+| SC-3 / D-13 / D-14 | activate, unban, unsuspend, deactivate, restore, ban, force delete with cleanup (throttle, pivot, avatar) | integration (plugin) | `... -run 'TestAdminUserActions|TestAdminUserForceDelete'` | ❌ Wave 0 |
+| SC-4 / T-12-18 | Without the extra permission: changing a privileged membership is 403 and nothing changes; privileged code create, rename and delete refused; with it: allowed; every other path leaves `users_groups` unchanged | integration (plugin) | `... -run 'TestAdminPrivilegedGroups'` | ❌ Wave 0 |
+| D-15 | Resolver equals PHP `getMergedPermissions` + `hasPermission` on a table of cases (group merge, user override, deny, wildcards, tolerant scan) | unit | `... -run 'TestMergedPermissions|TestPermissionSetScan'` | ❌ Wave 0 |
+| D-17 | `last_seen` written on login and refresh; absent from every user payload | integration | `... -run 'TestLastSeen'` plus the existing parity corpus | ❌ Wave 0 |
+| D-19 | Create with password and confirmation; mismatch 422; reset on update; `send_invite` sends one mail; password never in a response | integration | `... -run 'TestAdminUserPassword|TestAdminUserInvite'` | ❌ Wave 0 |
+| D-20 | An avatar uploaded through the admin file route appears in `apiArray`; one uploaded through the API appears in the admin file list | integration | `... -run 'TestAdminAvatarSharedWithAPI'` | ❌ Wave 0 |
+| D-08 | A user with groups marshals without groups | regression | existing `TestPhase12Threats/T-12-18` in the application | ✅ |
+| schema | Go schema versus PHP snapshot with one new allow-list entry | integration | `go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot -count=1` | ✅ (extend) |
+| migrations | Three additive migrations up and down | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/updates/... -count=1` | ✅ harness, ❌ cases |
+| docs | Identifiers, links, snippets | checker | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ |
+
+### Sampling Rate
+- **Per task commit:** the quick run command of the repository the task wrote to; plus `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` when `admin/` or swag annotations changed.
+- **Per wave merge:** both full suites and the docs checks.
+- **Phase gate:** a `scripts/check-phase12.1.sh` modelled on `scripts/check-phase12.2.sh` (stages `--go`, `--security`, `--spa`, `--openapi`, `--dist`, `--docs`, `--hygiene`, `--app`, `--all`), green before `/gsd-verify-work`.
+
+### Wave 0 Gaps
+- [ ] A neutral cabana fixture plugin (`modules/cabana/testdata/...`, `acme`) with bulk actions, record actions, a preview field, row state and a permission editor.
+- [ ] `sm-user-plugin` admin test harness: boot the plugin with cabana mounted, mint a backend principal with chosen permissions (the application's `admin_command_test.go` and the fonoteka plugin's admin tests show the pattern).
+- [ ] SPA fixtures under `admin/tests/fixtures/` for the new schema fields.
+- [ ] `scripts/check-phase12.1.sh`.
+- Framework install: none needed.
+
+## Security Domain
+
+`security_enforcement` is not set in `.planning/config.json`, so it is enabled.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes (admin sets and resets user passwords) | `bouncer.HashPassword` with the configured bcrypt cost; minimum length from `golem15.user.password.min_length`; the password never appears in a response or a log |
+| V3 Session Management | partly | A password reset by an admin should stamp `tokens_valid_after` so existing JWTs die, as the user's own change-password path does `[ASSUMED: confirm against ChangePassword]`; ban and deactivate rely on the login-time checks |
+| V4 Access Control | yes (core of the phase) | Backend guard, `RequiredPermissions`, per-action permissions, the privileged-groups permission, list and form scopes for every id |
+| V5 Input Validation | yes | Strict JSON decoding (`DisallowUnknownFields`) for action bodies, `normalizeIDs`, permission codes validated against the controller's option list, values against the mode's set |
+| V6 Cryptography | no new use | — |
+| V7 Logging | yes | `logAuth` on denials (existing); log bulk destructive actions with admin id, action and count, never record contents |
+
+### Known Threat Patterns
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Privilege escalation by adding a user to `admin` (T-12-18) | Elevation of Privilege | Server-side locked-id guard in the groups sync; 403 and rollback; test with and without the permission |
+| Escalation by renaming a group's code to a privileged one | Elevation of Privilege | D-06 check on create, update (both directions) and delete |
+| IDOR through bulk ids | Elevation of Privilege | `lockScoped` before plugin code; partial selection refused |
+| Running a record action on a record outside the form scope | Elevation of Privilege | `loadRecord` with `FormExtendQuery`; one 404 for missing and out-of-scope |
+| CSRF on action routes | Tampering | `requireAjax` on every new write route; cookie-only writes without the header already answer 403 |
+| Mass assignment of `is_activated`, `password`, `permissions`, `organisation_id` | Tampering | Protected fill keys stay protected by default; each exception is an explicit, typed path |
+| Password or hash disclosure | Information Disclosure | `password` type never projected; `models.User.Password` is `json:"-"`; opaque 500 bodies |
+| Frontend permission code injection | Tampering | Only codes present in the controller's option list are stored |
+| Mail abuse through bulk invite | Denial of Service | `send_invite` is create-only, one mail per created user; no bulk invite action |
+| Destructive bulk delete by mistake | Denial of Service | Standard confirm (D-13), one transaction, count in the response |
+| Fixture or doc naming the application | Information Disclosure (hygiene) | The existing hygiene stage refuses application names in the framework tree |
+| Account takeover by re-enabling a trashed site admin | Elevation of Privilege | Accepted with PHP parity; recorded in the security review (path 8) |
+
+The phase touches authorization, the plugin API and destructive bulk operations, so the security-review agent runs (CONTEXT.md, Claude's Discretion). Every mitigated threat should get a removal row in the phase gate, as Phases 12 and 14 did.
+
+## Sources
+
+### Primary (HIGH confidence, read this session)
+- `modules/cabana/`: `actions.go`, `contracts.go`, `schema_types.go`, `list_schema.go`, `form_schema.go`, `filter_schema.go`, `crud.go`, `http.go`, `query.go`, `registry.go`, `extension.go`, `relation.go` (parts), `relation_field.go`, `field_file.go` (parts), `tx_context.go`, `lang.go`, `admin_openapi.go` (outline)
+- `modules/pact/capabilities.go` (whole file)
+- `modules/lagoon/validate.go`, `modules/lagoon/attach/relation.go`, `attach/file.go` (parts)
+- `admin/package.json`, `admin/src/app/router.ts`, `controllerRoutes.ts`, `winterUrl.ts`, `components/form/registry.ts`, `control.ts`, `views/ListView.vue`, `components/list/ListToolbar.vue`, `components/form/fields/RelationField.vue` (part), `api/types.ts`
+- `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh`, `scripts/check-phase12.2.sh` (stage list)
+- `../fonoteka.go/plugins/golem15/user/`: `plugin.go`, `go.mod`, `config/config.yaml`, `models/*.go`, `classes/user_groups.go`, `classes/throttle.go`, `classes/user_lookup.go`, `controllers/api_controller.go` (login, refresh, avatar), `updates/*.go`, `README.md`
+- `../fonoteka.go/parity/schema_diff_test.go`, `parity/testdata/php_schema_snapshot.sql` (table list and `users`), `go.work`, `go.mod`, `.gitmodules`, `plugins/golem15/fonoteka/controllers/collections_admin_controller.go` and its YAML
+- PHP reference `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user`: `controllers/Users.php`, `UserGroups.php`, `Organisations.php`, every YAML and partial under `controllers/users`, `usergroups`, `organisations`, `models/user|usergroup|organisation/*.yaml`, `models/User.php` (lines 20-290, 370-750), `UserGroup.php`, `Organisation.php`, `Throttle.php`, `FrontendPermission.php`, `formwidgets/FrontendPermissionEditor.php` and its partial, `updates/v2.8.0/*`, `Plugin.php` (permissions, navigation), `views/mail/invite.htm`
+- `vendor/winter/storm/src/Auth/Models/Throttle.php`, `User.php` (`attemptActivation`, `hasAccess`, `hasPermission`, `setPermissionsAttribute`), `Auth/Manager.php` (`findThrottleByUserId`)
+- `.planning/`: `12.1-CONTEXT.md`, `REQUIREMENTS.md`, `STATE.md`, `ROADMAP.md` (Phase 12.1 and 12.2 sections), `12-01-PLAN.md` (T-12-18), `12-CONTEXT.md` (D-25), `10.1-CONTEXT.md` (D-12), `config.json`
+
+### Secondary (MEDIUM confidence)
+- None.
+
+### Tertiary (LOW confidence)
+- Training knowledge of GORM read-only field and `Count` behaviour (A6) and of Winter's pivot detach on delete (A9).
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - no new dependency; versions read from the module files
+- Current framework shape and PHP inventory: HIGH - read line by line this session
+- Gap list (G1 to G12): HIGH that each gap exists (code quoted); MEDIUM on the recommended answers (design choices)
+- New contract names and route shapes: MEDIUM - within Claude's discretion, unverified against the router
+- Pitfalls: HIGH for 1-4, 7-11, 13 (derived from code); MEDIUM for 5, 6, 12
+
+**Research date:** 2026-10-04
+**Valid until:** 2026-11-03 for the PHP inventory; 7 days for cabana line references if Phase 14.1 or other work touches `modules/cabana` first