docs(19.6.1): create phase plan

Three plans per D-22: server side, SPA support (D-21), tests.
D-12 amended: a password set for another admin keeps their personal
API tokens, as admin:reset-password does. Research open questions
marked resolved.
This commit is contained in:
Jakub Zych
2026-10-22 00:13:40 +02:00
parent a7431c34f0
commit 295c1c1ee7
9 changed files with 1481 additions and 18 deletions

View File

@@ -1423,10 +1423,17 @@ Plans:
2. Privilege rules hold on the server: only a superuser can create, edit or grant superuser; an admin cannot raise their own role or permissions; the last active superuser cannot be deactivated, demoted or deleted.
3. The record-action seam on the Administrators list is usable by plugins (19.7's link action), and unit tests cover the privilege rules.
**Plans:** 0 plans
**Plans:** 3 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.6.1 to break down)
**Wave 1**
- [ ] 19.6.1-01-PLAN.md — server side: the pact action-extension seam, the internal summercms.cabana pseudo-plugin with the Administrators and Roles screens, every privilege rule in the save transaction (superuser hiding, ceilings, self-edit, outranking, last-superuser lock, revocation), lang keys and docs
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 19.6.1-02-PLAN.md — SPA support (D-21): per-record locked permission values and the field `trigger`, regenerated OpenAPI and schema.d.ts, rebuilt modules/boardwalk/dist
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 19.6.1-03-PLAN.md — unit, Postgres and SPA tests for every rule (incl. the held-lock last-superuser race), the seam and D-21, plus the check-phase19.6.1.sh gate
### Phase 19.7: Generic MCP module (INSERTED)

View File

@@ -0,0 +1,460 @@
---
phase: 19.6.1-backend-administrators-screen
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/action_extensions.go
- modules/cabana/backend_screens.go
- modules/cabana/backend_users_admin.go
- modules/cabana/backend_roles_admin.go
- modules/cabana/backend_privileges.go
- modules/cabana/backendscreens/controllers/users/config_list.yaml
- modules/cabana/backendscreens/controllers/users/config_form.yaml
- modules/cabana/backendscreens/controllers/users/config_filter.yaml
- modules/cabana/backendscreens/controllers/users/_hint.htm
- modules/cabana/backendscreens/models/user/columns.yaml
- modules/cabana/backendscreens/models/user/fields.yaml
- modules/cabana/backendscreens/controllers/roles/config_list.yaml
- modules/cabana/backendscreens/controllers/roles/config_form.yaml
- modules/cabana/backendscreens/models/userrole/columns.yaml
- modules/cabana/backendscreens/models/userrole/fields.yaml
- modules/cabana/http.go
- modules/cabana/registry.go
- modules/cabana/contracts.go
- modules/cabana/crud.go
- modules/cabana/backend_screens_test.go
- modules/cabana/backend_screens_smoke_test.go
- modules/cabana/README.md
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- docs/backend/admin-controllers.md
- docs/backend/users-and-permissions.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-22]
estimate:
tokens: 330000
raw_tokens: 330000
tasks: 4
confidence: low
must_haves:
truths:
- "D-01, D-02, D-04: a superuser sees an Administrators tile (order 100) and a Roles tile (order 110) under Settings → System; the permission catalog gains backend.manage_users and backend.manage_roles granted to no role in code, so after an upgrade no developer or publisher admin sees a tile and the controller routes answer them 403."
- "D-14, D-15, SC-3: a plugin implementing pact.HasAdminActionExtensions adds record actions to any admin controller by ID, summercms.cabana.users included; they are offered on that controller's preview after the owner's actions, only to admins holding the target's RequiredPermissions and the action's Permissions and for whom Applies is true, and run through the existing row-locked RecordAction transaction; an unknown controller ID, a target without a preview block, a reserved or duplicate name, a nil Run, an empty label or an unknown permission stops boot."
- "An application whose plugins register no admin controllers still activates no admin: Activate returns nil routes without reading admin.jwt.secret, so examples/hello and the modules/surf admin-prefix tests pass unchanged."
- "D-03: for a non-superuser the Administrators list, search and filters never return a superuser, GET of a superuser id (show, preview partial, record action) answers 404, and the is_superuser field, column and filter are absent from the form and list schemas; a superuser sees all three."
- "D-05: a non-superuser create or update body carrying is_superuser (true or false, field hidden or not) answers 403 with backend::lang.user.superuser_forbidden on is_superuser and writes nothing; a superuser grants and removes it."
- "D-06: the Administrators form has a Permissions tab with a radio (inherit / allow / deny) permission editor stored in backend_users.permissions and merged last by FindByID."
- "D-07: a non-superuser cannot grant more than they hold: catalog permissions they lack are Locked options (a changed locked value answers 403 and the stored value is kept), roles whose stored plus code-declared grants they do not cover are locked in the role picker (assigning one answers 403 with user.role_ceiling, keeping an unchanged above-ceiling role still saves), and on the Roles screen codes they lack are locked."
- "D-08: an admin editing their own record changes login, email, first and last name; a change of their own role, superuser flag, permissions or activation answers 403 user.self_privileged (the role is compared after assignment and the save rolls back), deleting yourself answers 403 user.self_delete and deactivating yourself 403 user.self_deactivate; there is no redirect to My Account."
- "D-09: the last active superuser cannot be demoted (update), deactivated (record or bulk action) or soft-deleted (form or bulk delete); every reducing path counts the other active superusers inside the write transaction after pg_advisory_xact_lock, which is taken after the row lock, so concurrent demotions of the only two superusers leave one active superuser without a deadlock; admin:* commands never clear the flag and stay unchanged (SC-1)."
- "D-10: Activate and Deactivate are record and bulk actions toggling is_activated, Delete is a soft delete through deleted_at, the trashed filter lists deleted admins carrying the Deleted row state, Restore brings them back, and no code path hard-deletes a backend user."
- "D-11: password and confirmation are required on create and optional on update (blank keeps the password), at least 4 characters and at most 72 bytes, bcrypt-hashed at admin.password.bcrypt_cost like admin:create; a login or email already held (case-insensitive, deleted admins included) answers 422 on that field, saying when a deleted administrator holds it."
- "D-12: deactivate, soft delete and a password set for another admin set tokens_valid_after to now+1s, so the target's earlier JWTs answer 401 on the next request; deactivate and delete also stop personal API tokens (FindByID refuses inactive and deleted users); restore and activate leave tokens_valid_after unchanged; a password set leaves personal API tokens working, exactly as admin:reset-password does, and the docs say so."
- "D-13: creating an administrator sends no mail and offers no invite option."
- "D-16: the Roles form has name, code (slug preset from name), description and a Permissions tab with a checkbox permission editor; changing the code of a system role (is_system, or a role code-declared grants attach to) answers 422 user.role.code_locked on code; name, description and stored grants stay editable within the D-07 ceiling."
- "D-17: deleting a role that any admin holds, soft-deleted admins included, answers 403 whose message names the role and the count; deleting a system role answers 403 user.role.delete_system; a refused row in a bulk delete rolls back the whole selection."
- "D-19: a non-superuser holding backend.manage_users gets 403 user.outranked when updating, deleting, or running a record or bulk action on a non-superuser admin whose effective grants (role JSON, code-declared role grants, user overrides) their own grants do not cover."
- "D-20: a password sent for your own record on the Administrators form answers 403 user.own_password on password; My Account, which checks the current password, stays the place to change it."
- "Pitfall 1: GET of a superuser's record returns data.is_superuser true to a superuser, and saving that form untouched keeps the flag."
- "D-18, D-22: this plan is the server side of the three-plan split; cabana and pact READMEs and docs/backend/admin-controllers.md and users-and-permissions.md change in the same commits as the API, and TestDocsTree plus summer docs:build --check pass."
- "UI: the Administrators list shows backend::lang.user.list_empty when a search or filter matches nothing (an unfiltered list always holds the viewer) and the Roles list shows user.role.list_empty (seeded system roles keep it non-empty unfiltered); the existing loading skeletons, list.load_failed and form.load_failed alerts (also the D-03 404) and empty-search copy apply unchanged."
- "UI: Administrators list columns are login, first name, last name, email, role (dash when empty), last sign-in (dash when never), superuser (superusers only) and an invisible created column; active, deactivated, deleted and deleted+deactivated rows coexist, each state a text badge in RowStateBadges order; 20 rows per page with the existing pagination and horizontal scroll."
- "UI: bulk Activate, Deactivate and Restore act on the selection (menu enabled only with a selection); one refused row rolls the whole selection back and the danger toast shows the refusal; every refusal that can come back from a delete or an action is a ForbiddenError with Message set (UI-SPEC C8)."
- "UI: the Administrators preview shows at most one status callout by precedence deleted (danger) > deactivated (warning) > own account (warning), then at most one of Activate / Deactivate / Restore, then contributed actions in boot merge order wrapping with the footer, then Edit administrator; Deactivate is not offered on your own record; an action's 403 is a danger toast with the server message, 409 the existing stale toast, 404 the load-failed state."
- "UI: the create form starts blank with password and confirmation required (422 field lines and count banner on a miss or mismatch); the Account tab pairs login/email, first/last name and password/confirmation, then a full-width role picker that shows above-ceiling roles locked; the Roles create form starts unchecked with codes the editor lacks shown as locked rows; long values scroll inside the inputs and the header title truncates."
- "UI: a non-superuser editor sees no Superuser switch, column or filter, locked permission rows for codes they lack and locked roles above their ceiling; a 403 save shows the forbidden banner with the server message plus field lines and keeps values; a 422 shows field lines, the count banner and activates the first invalid field's tab."
- "UI: each Settings tile is listed only to holders of its code or superusers, so an admin holding only backend.manage_roles sees only Roles; a Roles bulk delete containing a role still assigned or a system role refuses the whole selection with a danger toast naming that role."
artifacts:
- path: "modules/pact/capabilities.go"
provides: "AdminActionExtension and HasAdminActionExtensions"
contains: "type HasAdminActionExtensions interface"
- path: "modules/cabana/action_extensions.go"
provides: "boot-time merge of contributed record actions"
contains: "func compileActionExtensions("
- path: "modules/cabana/backend_screens.go"
provides: "internal summercms.cabana pseudo-plugin: controllers, permissions, settings links, AdminFS"
contains: "AdministratorsControllerID"
- path: "modules/cabana/backend_users_admin.go"
provides: "summercms.cabana.users controller with hooks, providers and actions"
contains: "FormAfterUpdate"
- path: "modules/cabana/backend_roles_admin.go"
provides: "summercms.cabana.roles controller"
contains: "FormBeforeDelete"
- path: "modules/cabana/backend_privileges.go"
provides: "actsAsSuperuser, effectiveGrants, grantsCovered, guardOutranked, guardLastSuperuser"
contains: "pg_advisory_xact_lock"
- path: "modules/cabana/backendscreens/models/user/fields.yaml"
provides: "Administrators form (Account and Permissions tabs)"
contains: "permissioneditor"
- path: "modules/cabana/backend_screens_smoke_test.go"
provides: "Postgres smoke tests of the four slices"
contains: "TestBackendScreensPostgresSmoke"
key_links:
- from: "modules/cabana/http.go"
to: "modules/cabana/action_extensions.go"
via: "Activate merges contributions after compiling controllers and before compileContributions validates permissions"
pattern: "compileActionExtensions\\(reg, "
- from: "modules/cabana/http.go"
to: "modules/cabana/backend_screens.go"
via: "Activate appends the pseudo-plugin only after the no-controllers early return"
pattern: "withBackendScreens\\("
- from: "modules/cabana/backend_users_admin.go"
to: "modules/cabana/backend_privileges.go"
via: "every reducing write path calls the last-superuser guard inside the write tx"
pattern: "guardLastSuperuser\\("
- from: "modules/cabana/crud.go"
to: "modules/cabana/backend_users_admin.go"
via: "projectFullRecord asks the unexported data extender so is_superuser loads its stored value"
pattern: "extendRecordData\\("
- from: "modules/cabana/http.go"
to: "modules/cabana/backend_users_admin.go"
via: "formSchema and listSchema drop per-request hidden fields, columns and filters"
pattern: "hiddenSchemaFields\\("
prohibitions:
- statement: "Existing cabana payloads (record, list, schema, settings, navigation, /auth/me, /account) keep their shape for every controller other than the two new ones, and admin:* commands keep their behaviour and output: modules/cabana/commands.go, token_commands.go and account.go are not modified."
status: unresolved
verification: judgment
- statement: "No code path hard-deletes a backend user; tokens, linked_by references and attachments keep valid foreign keys."
status: unresolved
verification: judgment
- statement: "Neither backend.manage_users nor backend.manage_roles is granted to the developer, publisher or any other role through code-declared pact.Permission roles."
status: unresolved
verification: judgment
- statement: "No invite or welcome mail, send-reset-link action, force-change-on-next-login, impersonation, user groups, per-row list action menu or top-level navigation item is added (CONTEXT Deferred Ideas, D-02, D-13, D-15)."
status: unresolved
verification: judgment
- statement: "Framework code, READMEs, docs and tests name no consuming application; no new dependency is added and YAML stays on goccy/go-yaml."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; MVP mode is off, no story is invented): cabana gets WinterCMS's Administrators screens, so backend admins and their roles are managed in the admin SPA instead of only through `admin:*` console commands, with the privilege rules on the server and a record-action seam other plugins (19.7's link action) use.
This plan's slice (plan 1 of 3, wave 1, D-22): the whole server side. The tracer wires the new `pact` seam, the internal `summercms.cabana` pseudo-plugin, the Settings tile and the Administrators list and preview end to end, with a contributed record action running on the preview. Task 2 adds the form and every save-path rule, Task 3 the lifecycle actions and the last-superuser lock, Task 4 the Roles screen. Plan 02 adds the SPA parts of D-21 (field `trigger`, per-record permission locks), Plan 03 the unit and Postgres tests.
## Spec-less probe fallback
Spec-less probe fallback skipped for edges: no requirement IDs (phase_req_ids null) and no SPEC.md; visible choice. Edge rows come from 19.6.1-UI-SPEC.md "UI Considerations" instead: the covered rows for the server-rendered screens are lifted into `must_haves.truths` above (lines starting "UI:"), the D-21 rows go to Plan 02, the five backstop rows to Plan 03, and the dismissed row (static note text has no loading state) is not lifted. Prohibitions were recalled by hand (fallback §B) and authored descriptor-less.
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
- IDs (CONTEXT discretion, RESEARCH A4): plugin `summercms.cabana`, controllers `summercms.cabana.users` and `summercms.cabana.roles` (SPA routes `/backend/summercms/cabana/...`); `summercms` is not a reserved first segment (registry.go:46). The migration history id `summercms.cabana` is untouched; no migration.
- The pseudo-plugin is appended only when some real plugin registers admin controllers (RESEARCH Pattern 1, Pitfall 3). A plugin that only contributes record actions does not switch the admin on; its contributions are compiled whenever the admin is active.
- The D-14 seam carries record actions only (CONTEXT discretion, RESEARCH A5). `AdminActionExtension` is a struct so bulk or toolbar fields can be added later without breaking implementers. A record action still carries no input beyond the record (RESEARCH Open Question 5, for 19.7 to design).
- 19.6 D-15 ("Reset 2FA" contributed by an MFA plugin to the user plugin's Users screen) can use the seam as is: that screen has a preview block; the contributed name must not collide with a name the owner registers (RESEARCH Pattern 2).
- D-09 lock strategy (CONTEXT discretion): one `pg_advisory_xact_lock` on a fixed key taken after the row lock, plus a count in the write transaction (RESEARCH Pattern 4; a `FOR UPDATE` on the superuser set would deadlock).
- D-09 reachability: through the screens a single request cannot remove the last active superuser, because only an active superuser sees superusers (D-03) and changes to your own flag, activation or row are refused first (D-08), so the acting superuser always remains. The guard closes the concurrent case (superuser A demotes, deactivates or deletes B while B does the same to A) and any later write path. The smoke tests therefore exercise the self refusals; Plan 03 proves every path with a race held on the advisory lock.
- D-07 wildcard rule (RESEARCH A3): a role's wildcard grant counts as covered only when the editor holds the same or a broader wildcard.
- D-12 and personal API tokens (RESEARCH Pitfall 7, Open Question 6): a password set for another admin bumps `tokens_valid_after` (JWTs stop) and leaves personal API tokens working, exactly like `admin:reset-password`; deactivate and delete stop both. Documented in docs/backend/users-and-permissions.md. Revoking personal tokens on a password set would widen the phase; the user confirmed this reading on 2026-10-22 and D-12 is amended in CONTEXT.
- D-17 refusal shape: the delete answers 403 `ForbiddenError` with a translated message naming the role and the count, not a 422 `ValidationError`, because the SPA delete toast shows only `error.message` (UI-SPEC C8 note). This deviates from CONTEXT's word "validation error"; the visible requirement (the toast names the count) holds without an SPA change.
- D-16 system-role code change answers 422 on `code` (UI-SPEC C8), a field error on a save, not a delete refusal.
- Roles list has no users-count column (RESEARCH Open Question 7; cabana columns have no count key).
- The `is_superuser` projection and the per-request schema drops use unexported in-package interfaces, so no `pact` surface and no payload change for other controllers.
- Lifecycle copy, filters and columns follow UI-SPEC C2-C5 exactly; all `backend::lang.user.*`, `user.role.*`, `permissions.*` and `settings.category_system` copy lands in Task 1 (en and pl); the D-21 framework copy lands with Plan 02.
## Source coverage (whole phase)
| Source | Item | Plan | Status |
|---|---|---|---|
| GOAL | Administrators and Roles managed in the SPA with server-side rules and a plugin seam | 01-03 | COVERED |
| SC-1 | list, create, edit, deactivate admins; edit roles with the permission editor; payloads and admin:* unchanged | 01 (T1-T4), 03 | COVERED |
| SC-2 | only a superuser grants superuser; no self-raise; last active superuser protected | 01 (T2, T3), 03 | COVERED |
| SC-3 | record-action seam usable by plugins; unit tests for the rules | 01 (T1), 03 | COVERED |
| RESEARCH | Activation caveat, protected keys, save order, Pitfalls 1-10, advisory lock, ceilings, settings links | 01 | COVERED |
| CONTEXT | D-01..D-20 | 01 | COVERED |
| CONTEXT | D-21 (trigger, record locks, OpenAPI, dist) | 02 | COVERED |
| CONTEXT | D-18 (tests last, docs with the code), D-22 (three plans) | 01, 02, 03 | COVERED |
| UI-SPEC | C1-C5, C8, copy | 01 | COVERED |
| UI-SPEC | C6, C7, framework copy | 02 | COVERED |
| UI-SPEC | backstop rows | 03 | COVERED |
## Artifacts this phase produces (this plan)
- pact: `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }`, `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }`.
- cabana exported: constants `PermissionManageUsers` ("backend.manage_users"), `PermissionManageRoles` ("backend.manage_roles"), `AdministratorsControllerID` ("summercms.cabana.users"), `RolesControllerID` ("summercms.cabana.roles"); methods `BackendUser.Fillable`, `BackendUser.Rules`, `BackendUser.FilterScopes`, `BackendUser.FilterScope`, `BackendUserRole.Fillable`, `BackendUserRole.Rules`.
- cabana unexported: `backendScreens` pseudo-plugin and `withBackendScreens`, `compileActionExtensions`, `Registry.permissionOrder`, `CompiledController` contributor map, `usersAdmin`, `rolesAdmin`, `actsAsSuperuser`, `roleGrantSet`, `effectiveGrants`, `grantsCovered`, `guardOutranked`, `guardLastSuperuser`, `superuserLockKey`, `revocationCutoff`, `recordDataExtender` (`extendRecordData`), `schemaFieldHider` (`hiddenSchemaFields`), `BackendUser.priorRoleID` and `priorLoaded`, filter scope names `filterByRole` and `filterTrashed`.
- Admin screens: controllers `summercms.cabana.users` (list, form, preview with hint, filters, record and bulk actions activate / deactivate / restore) and `summercms.cabana.roles` (list, form); settings link entries `administrators` and `roles` in category `backend::lang.settings.category_system`.
- Embedded YAML and partial under `modules/cabana/backendscreens/`.
- Lang groups `backend::lang.user` (with `user.role`), `backend::lang.permissions`, key `backend::lang.settings.category_system` (en, pl).
- Smoke tests `TestBackendScreensSmoke` (-short), `TestBackendScreensPostgresSmoke`, `TestAdministratorsFormSmoke`, `TestAdministratorsLifecycleSmoke`, `TestRolesSmoke` (Postgres).
<objective>
Build the server side of the Administrators and Roles screens (D-22 plan 01): the D-14 `pact` action-extension seam, the internal `summercms.cabana` pseudo-plugin with both controllers, every privilege rule of D-03 to D-12, D-16, D-17, D-19 and D-20 enforced in the save transaction, the lang keys and the docs.
Purpose: SC-1, SC-2, SC-3 (seam); D-01..D-20.
Output: four framework commits (each with its README and docs changes and a smoke test), no change to commands.go, token_commands.go or account.go.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-PATTERNS.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
Concurrency and commits: 19.6, 19.7 and 19.8 are in flight and touch cabana. Before each task run `git status --porcelain -- modules/cabana modules/pact modules/phrasebook docs` and `git log --oneline -5 -- modules/cabana/http.go modules/cabana/crud.go`; stop and report when a file this task edits carries someone else's uncommitted change. Re-read the `crud.go` save order (lines ~771-848) and `Activate` (http.go:74-210) before editing them (RESEARCH "Valid until"). Record the plan base as `PLAN_BASE=$(git rev-parse HEAD)` before Task 1. Stage by explicit path, never `git add -A`; leave STATE.md and state.json alone; no co-author tags; READMEs and docs in the same commit as the code they describe; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit. Docker-backed tests skip under -short; on this host run them, through `sudo -u golem` if the Docker socket is denied (CLAUDE.md); a Docker test that could not run is NOT RUN in the SUMMARY, never passed.
Reference only (read, never modify): `../sm-grzybyfunkcjonalne-app/plugins/golem15/user/` (abbreviated USER/ below) and `../examples/golem15-wintercms-starter/modules/backend/`.
<interfaces>
- pact (modules/pact/capabilities.go): `AdminController{ID, ModelName, ConfigDir}` :179, `HasAdminControllers` :186, `AdminAssets{AdminFS() fs.FS}` :192, `AdminPermissioned` :197, `AdminRecordSource{NewRecord() any}` :203, `AdminBulkAction{Name, Label, Confirm, Permissions, Run, Detached}` :286 with `AdminBulkActionInput{Records []any}` and `AdminBulkActionResult{Message, Affected}`, `HasAdminBulkActions` :325, `AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Applies, Run}` :343, `AdminRecordActionInput{RecordID uint64, Record any}` :360, `AdminRecordActionResult{Message}` :368, `HasAdminRecordActions` :374, `AdminPartialData.PartialData(ctx, name, record)` :385, `Permission{Code, Tab, Label, Roles}` :512, `HasPermissions` :520, `SettingsItem{Code, Label, Description, Category, Icon, Model, Order, Keywords, Permissions, Controller, Form, NewModel}` :570, `HasSettings` :590, `ListExtendQuery` :720, `RowState` constants `RowStateDeleted`, `RowStateDisabled` :726-741, `ListRowStates` :744, `FormRecordTitle` :772, `FormExtendQuery` :777, `FormBeforeCreate/Update`, `FormAfterCreate/Update`, `FormBeforeDelete`, `FormAfterDelete` :782-808, `FormVirtualFields` :831, `FormRules(ctx, op)` :842, `FilterScope{FilterScopes() []string; FilterScope(name, db, value) *gorm.DB}` and `FilterOptions(scope) []Option` (near :908-922).
- party (modules/party/registry.go:16-21): `Plugin{ID() string; Requires() []string; Register(*backpack.App) error; Boot(*backpack.App) error}`.
- cabana: `Activate(app, plugins) (*Routes, error)` http.go:74 (early return `if len(items) == 0` :79, compileRegistryWithTypes :103, checkDeferredModels :122, compileContributions :125, compileTokenScopes :128, validateMessageKeys :135); `formSchema` http.go:753 (per-request `kept` slice :771-785), `listSchema` http.go:801 (new slices for toolbar and bulk actions), `protect` http.go:1101; `collectControllers` registry.go:22 (controller ID must start with plugin ID), `reservedVendorSegments` registry.go:46, `compilePermissionCatalog` registry.go:~212-246, `compileContributions` registry.go:268 (validates RecordActions permissions :342-346, settings link controllers), `permissionCode` :366, `rolePermissions(role) map[string]bool` :449; `compileExtension` extension.go:60 (preview rule :91-93), `compileRecordActions` extension.go:405, `builtinToolbarActions` extension.go:47, `virtualFieldTypes` and `checkVirtualFields` extension.go:156-175; `CompiledController` contracts.go:97 (Form.recordActions is unexported `[]string`, schema_types.go:159; RecordActions map), `Registry` contracts.go:146, `Allows`, `granted` contracts.go:214-248, `requiredOf`; `BackendUser`, `BackendUserRole` contracts.go:17-58 (BackendUser has a `Role BackendUserRole` association field); `FindByID` merge auth.go:39-67, `applyUserPermissions` auth.go:73, `principalFrom` auth.go:101, `parseGrants` auth.go:118, `adminBcryptCost(app)` auth.go:481; `ValidationError{Details}` crud.go:69, `ForbiddenError{Message, Details}` crud.go:83 (Message is a phrase key or text, no params), `localizeControllerError` crud.go:98; `offeredRecordActions` crud.go:551, `RecordAction` crud.go:586, `projectFullRecord` crud.go:647, `save` crud.go:672 (order: Fill, Validate, formBeforeUpdate :800, applyPermissionValues :809, checkRelationScope, checkRelationLocks :819, assignBelongsTo :822, `tx.Unscoped().Save` :829, formAfterUpdate :848), `newWritableModel` crud.go:907 (model needs lagoon.HasFillable and Rules), `loadRecord` crud.go:937 (FormExtendQuery then FOR UPDATE), `deleteRecord` crud.go:1127 (GORM Delete on the tx root, soft delete), bulk delete loop crud.go:355-373, `protectedFillKey` crud.go:1407 (role_id, permissions, is_superuser, is_system, is_activated, password); `recordActionOf` actions.go:242; `TxFromContext`, `VirtualFieldsFromContext` (only keys present in the body) tx_context.go:27,54; `PermissionOption{Code, Label, Tab, Comment, Locked}`, `PermissionEditorProvider{AdminPermissionOptions, AdminPermissionValues, AdminSetPermissionValues}`, `applyPermissionValues` field_permission.go:25-65, 260; `FieldRelationContract{Field, Kind, NewRelated, ForeignKey, LabelColumn, WritableForeignKey}`, `RelationLock{IDs, Message}`, `RelationLockProvider.AdminRelationLocks(ctx, field)` relation_field.go:22-66; `assetPath` schema.go:41 (`~/plugins/summercms/cabana/x` resolves to `x` in the AdminFS); `compileSettingLink` settings.go:57; `passwordMaxBytes = 72` and `accountMessage(ctx, tr, rule, field, params)` account.go:47, 335-336; admin:create uniqueness query commands.go:96 and defaults :106-115, reset cutoff `time.Now().UTC().Add(time.Second)` commands.go:157.
- phrasebook: `(*Translator).Choice(ctx, key, count, params map[string]string) string`, `Get(ctx, key, params)` translator.go:152-178.
- bouncer: `Principal{ID, Backend, IsSuperuser, PermissionGrants map[string]bool, Scope *ScopeLimit{Full bool; Scopes}}` context.go:26-35, `User(ctx)` :46, `HashPassword(cost, plain)`.
- Test helpers (package cabana_test, modules/cabana/auth_test.go): `adminGorm(t)` :321 (skips under -short), `adminHandler(t, gdb, mutate)` :367, `insertAdmin(t, gdb, login, email, password, activated, deleted)` :403 (developer role, never superuser), `demoPlugin`; env pattern `newActEnv` with `phrasebook.Activate`, `surf.Assemble`, `postJSON`, `adminAPI`, `accessToken`, `adminTestPassword` in phase101_actions_test.go:233-346. Internal fixtures (package cabana): `contributionPlugin`, `metadataRegistry`, `metadataPlugin` used in settings_link_test.go:1-60. External hooks into internals go through modules/cabana/export_test.go.
- Analogs: USER/admin.go:11-38 (embed + AdminFS + interface asserts), USER/controllers/users_admin_controller.go (identity :41-51, unscoped queries :85-97, row states :102-129, virtual fields and rules :135-155, FilterOptions :168, guardPrivilegedMember :193-209, FormBeforeUpdate/Create :217-275, storePassword :303-321, AdminFieldRelations :370, AdminRelationLocks :402-422, permission provider :426-477, stored-row re-read :479-497, FormBeforeDelete :524, bulk actions :589-627, record actions :665-743, PartialData :746), USER/controllers/users/{config_list,config_form,config_filter}.yaml, USER/controllers/users/_hint.htm, USER/models/user/{columns,fields}.yaml, USER/controllers/usergroups/*.yaml; Winter `modules/backend/controllers/Users.php`, `UserRoles.php`, `models/user/fields.yaml`, `models/userrole/fields.yaml`, `formwidgets/PermissionEditor.php` (~93-150).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a superuser opens Settings → System → Administrators, browses administrators, opens one's preview, and runs a record action another plugin contributed through the new pact seam</name>
<reversibility rating="costly">D-14 publishes a pact capability that 19.7 and the MFA plugin implement, and D-01's permission codes get stored in role and user permission JSON on every install that grants them.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/action_extensions.go, modules/cabana/backend_screens.go, modules/cabana/backend_users_admin.go, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/models/user/columns.yaml, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/contracts.go, modules/cabana/backend_screens_test.go, modules/cabana/backend_screens_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/cabana/README.md, docs/backend/admin-controllers.md</files>
<read_first>modules/pact/capabilities.go (:179-205, :325-390, :505-595), modules/pact/README.md (Features line 30, API table :130-140), modules/cabana/http.go (:74-210), modules/cabana/registry.go (:1-80, :205-360, :440-460), modules/cabana/extension.go (:30-130, :395-429), modules/cabana/contracts.go, modules/cabana/crud.go (:546-640), modules/cabana/actions.go (:230-270), modules/cabana/settings.go (:57-65), modules/cabana/navigation.go (:43-152), modules/cabana/schema.go (:30-60), modules/cabana/settings_link_test.go, modules/cabana/export_test.go, modules/cabana/phase101_actions_test.go (:225-346), modules/cabana/auth_test.go (:314-440), modules/surf/admin_prefix_test.go (:91-99, :194), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (top-level groups, settings: and permissioneditor: blocks), USER/admin.go, USER/controllers/users/config_list.yaml, USER/controllers/users/config_form.yaml, USER/models/user/columns.yaml, USER/models/user/fields.yaml, docs/backend/admin-controllers.md (:464-500), modules/cabana/README.md, 19.6.1-UI-SPEC.md (Copywriting Contract, C1-C4), 19.6.1-RESEARCH.md (Patterns 1 and 2, Pitfalls 3 and 8, Code Examples "A contribution from another plugin")</read_first>
<action>Per D-01, D-02, D-04, D-14, D-15 and SC-3.
pact (capabilities.go, additive, next to HasAdminRecordActions): `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }` and the plugin-level `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }`. Doc comments: Controller is the ID (vendor.plugin.controller) of any activated plugin's admin controller, cabana's own included; each action keeps its own Permissions, Applies, Confirm and Danger and runs exactly like the owner's record actions; contributions are offered on the target's preview screen after the owner's actions, in plugin activation order; the admin also checks the target controller's RequiredPermissions; record actions only for now, the struct leaves room for more kinds. The comment names no consumer. pact README: extend the admin-extension Features sentence and add two API-table rows.
cabana/action_extensions.go (new): `compileActionExtensions(reg *Registry, plugins []party.Plugin) error`. For each plugin implementing HasAdminActionExtensions, in slice order, for each extension: the target is `reg.byID[ext.Controller]`, a missing one is an error naming the plugin and the controller ID; the target needs a compiled form with a preview block (error otherwise: record actions are offered on the preview screen); each action passes the checks compileRecordActions applies (identifier name, not create or delete, non-nil Run) and has a non-empty Label; a name already present in the target's RecordActions (the owner's or an earlier contribution) is an error naming both plugins. Merge by building a new recordActions slice for the target form (copy, then append the name; never append into the compiled backing array) and adding the action to `cc.RecordActions`; keep the contributing plugin per name in a new unexported map on CompiledController (contracts.go) for the error text. offeredRecordActions, recordActionOf and RecordAction then serve contributed actions unchanged; do not edit them.
registry.go: compilePermissionCatalog also appends each new code to a new unexported `permissionOrder []string` on Registry (contracts.go), in activation order, for stable editor display.
cabana/backend_screens.go (new): exported constants `PermissionManageUsers = "backend.manage_users"`, `PermissionManageRoles = "backend.manage_roles"`, `AdministratorsControllerID = "summercms.cabana.users"`, `RolesControllerID = "summercms.cabana.roles"`; the unexported pseudo-plugin `backendScreens{app *backpack.App; reg *Registry}` implementing party.Plugin (ID "summercms.cabana", Requires nil, no-op Register and Boot), pact.AdminAssets (AdminFS returns fs.Sub of a go:embed of the backendscreens directory, so paths start at controllers/ and models/), pact.HasAdminControllers (the Administrators controller; Roles joins in Task 4), pact.HasPermissions and pact.HasSettings, with compile-time interface assertions. Permissions(): both codes, Tab backend::lang.permissions.tab_system, Label backend::lang.permissions.manage_users and backend::lang.permissions.manage_roles; leave the Roles field of both entries empty (D-04). Settings(): one link entry, Code administrators, Controller AdministratorsControllerID, Permissions [PermissionManageUsers], Label backend::lang.user.menu_label, Description backend::lang.user.menu_description, Category backend::lang.settings.category_system, Icon users, Order 100 (D-02; the roles entry joins in Task 4). The pseudo-plugin declares no navigation entry (D-02). `withBackendScreens(app, plugins) ([]party.Plugin, *backendScreens)` returns a new slice with the pseudo-plugin appended (the caller's slice is never mutated).
http.go Activate: keep the `len(items) == 0` early return exactly where it is (no controllers: no admin routes, no secret check; examples/hello and surf's admin-prefix test rely on it). After it call withBackendScreens, re-run collectControllers on the extended slice and use that slice for every later step (checkReservedSegments, compileFieldTypes, compileRegistryWithTypes, checkDeferredModels, the new compileActionExtensions placed after checkDeferredModels and before compileContributions, compileContributions, compileTokenScopes). After compileContributions set `screens.reg = reg`. Update Activate's doc comment.
cabana/backend_users_admin.go (new): `usersAdmin{screens *backendScreens}` (lazy app and registry access, as the analog's app func): ID AdministratorsControllerID, ModelName `Backend\Models\User`, ConfigDir controllers/users, RequiredPermissions [PermissionManageUsers], NewRecord `&BackendUser{}`, FormRecordTitle ("First Last" when either name is set, else the login), AdminFieldRelations with the role contract (Field role, Kind belongsTo, NewRelated BackendUserRole, ForeignKey role_id, LabelColumn name, WritableForeignKey true). contracts.go: BackendUser gains `Fillable()` (login, email, first_name, last_name) and `Rules()` (login required|max:255, email required|email|max:255); BackendUserRole gains `Fillable()` (name, code, description) and `Rules()` (name required|max:255|unique:backend_user_roles, code required|max:255). Methods only; no struct field changes in this task.
YAML under backendscreens/ (shapes from USER/controllers/users/*.yaml and UI-SPEC C2-C4): controllers/users/config_list.yaml (list models/user/columns.yaml, modelClass `Backend\Models\User`, title backend::lang.user.list_title, recordUrl summercms/cabana/users/preview/:id, noRecordsMessage backend::lang.user.list_empty, recordsPerPage 20, showSetup true, toolbar search prompt backend::lang.list.search_prompt and no buttons yet: create and delete come with their guards in Tasks 2 and 3); controllers/users/config_form.yaml (name backend::lang.user.name, form models/user/fields.yaml, modelClass, defaultRedirect summercms/cabana/users, update redirect summercms/cabana/users/update/:id and redirectClose summercms/cabana/users/preview/:id, a preview block, messages update/saved/edit backend::lang.user.edit/preview backend::lang.user.preview); models/user/columns.yaml in UI-SPEC C2 order (login, first_name, last_name, email searchable and sortable; role relation role select name sortable; last_login datetime sortable; is_superuser switch; created_at datetime invisible sortable); models/user/fields.yaml Account tab (backend::lang.user.account) with login left, email right, first_name left, last_name right, role (type relation, nameFrom name, emptyOption backend::lang.user.role_none, comment backend::lang.user.role_comment, span full) and last_login (datepicker, context preview, span left).
Lang (en and pl, modules/phrasebook/backend/lang): every key of UI-SPEC "Copywriting Contract" except the "Framework additions (D-21)" table, which Plan 02 adds: new groups `user` (with the nested `role` block) and `permissions`, and `settings.category_system`. Plural texts are CLDR maps (en one/other, pl one/few/many/other), placeholders :name and :count, values verbatim from the UI-SPEC tables.
Smoke tests (Plan 03 extends both files). modules/cabana/backend_screens_test.go (package cabana, Docker-free): `TestBackendScreensSmoke` compiles, through withBackendScreens and the same compile steps Activate uses (or an unexported helper factored out of Activate for both), an extender plugin that owns one controller with a form and preview (reuse an existing internal fixture where one fits) and contributes record action acme_ping to AdministratorsControllerID with a permission it declares; asserts the Administrators controller's compiled recordActions end with acme_ping and RecordActions holds it; the catalog holds both backend codes and roleGrants for developer and publisher hold neither; settingsList for a superuser principal lists administrators with controller summercms.cabana.users and for a backend principal without grants does not; a contribution to acme.missing.things fails compilation with an error containing that ID; Activate with a plugin that registers no admin controllers returns nil routes and a nil error. modules/cabana/backend_screens_smoke_test.go (package cabana_test, Postgres through adminGorm): `TestBackendScreensPostgresSmoke` assembles like newActEnv (phrasebook.Activate, surf.Assemble) with demoPlugin plus a contributor plugin whose acme_ping Run asserts cabana.TxFromContext and records RecordID; seeds a superuser (insertAdmin, then UPDATE is_superuser = TRUE) and a developer-role admin with unique logins; as the superuser: GET /summercms/cabana/users lists both, GET /summercms/cabana/users/{id} carries acme_ping in meta.actions, POST /summercms/cabana/users/{id}/actions/acme_ping answers 200 and Run saw that id, GET /settings lists administrators; as the developer admin GET /summercms/cabana/users answers 403.
Existing tests: every test that assembles the admin with a controller-owning plugin now also activates the pseudo-plugin. If an existing test enumerates a superuser's settings entries or the permission catalog, update only that expectation to include the new entries and name each file in the SUMMARY; any other change in an existing test's result is a regression to fix in the code.
Docs in this commit: cabana README (Features: the built-in Administrators screen and cross-controller record actions; API reference rows for the four constants and the new BackendUser and BackendUserRole methods; the screens activate only when a plugin has admin controllers); docs/backend/admin-controllers.md, after "Record actions", a section "Adding record actions to another controller" with a neutral acme example contributing to summercms.cabana.users, the boot errors, that the target's RequiredPermissions and the action's own Permissions both apply, that a contributed name must not reuse one the owner registers, that a record action carries no input beyond the record, and that contributions are compiled only when the admin is active. Commit: `feat(cabana): Administrators screen and cross-controller record actions`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/cabana/... ./modules/pact/... ./modules/surf/... &amp;&amp; go test -count=1 -short -run '^TestBackendScreensSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -q -- '^--- PASS: TestBackendScreensSmoke' &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit from vet, the short suites, hello or the docs checker, or no "--- PASS: TestBackendScreensSmoke" line (the smoke failed or matched no test).</fails_when>
<automated>go test -count=1 -run '^TestBackendScreensPostgresSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -q -- '^--- PASS: TestBackendScreensPostgresSmoke' &amp;&amp; go test -count=1 ./modules/cabana ./modules/surf</automated>
<fails_when>No "--- PASS: TestBackendScreensPostgresSmoke" line (failed, or "--- SKIP" because Docker was unreachable: rerun through sudo -u golem, else record NOT RUN), or the full Docker-backed cabana and surf suites exit non-zero.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'type HasAdminActionExtensions interface' modules/pact/capabilities.go` prints 1 and `grep -c 'type AdminActionExtension struct' modules/pact/capabilities.go` prints 1.
- `grep -c 'func compileActionExtensions(' modules/cabana/action_extensions.go` prints 1.
- `awk '/compileActionExtensions\(reg/{a=NR} /compileContributions\(reg/{c=NR} END{exit !(a &amp;&amp; c &amp;&amp; a&lt;c)}' modules/cabana/http.go` exits 0 (the merge runs before permission validation).
- `awk '/len\(items\) == 0/{e=NR} /withBackendScreens\(/{w=NR} END{exit !(e &amp;&amp; w &amp;&amp; e&lt;w)}' modules/cabana/http.go` exits 0 (the pseudo-plugin is appended after the no-controllers return).
- `grep -v '^\s*//' modules/cabana/backend_screens.go | grep -c 'Roles:'` prints 0.
- `grep -c '"backend.manage_users"' modules/cabana/backend_screens.go` prints 1 and `grep -c '"summercms.cabana.users"' modules/cabana/backend_screens.go` prints 1.
- `grep -c 'pact.HasNavigation' modules/cabana/backend_screens.go` prints 0.
- `grep -c '^user:' modules/phrasebook/backend/lang/en/lang.yaml` prints 1, `grep -c '^user:' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1, `grep -c '^permissions:' modules/phrasebook/backend/lang/en/lang.yaml` prints 1, and `grep -c 'category_system' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
- `grep -c 'summercms.cabana.users' docs/backend/admin-controllers.md` prints at least 1 and `grep -c 'HasAdminActionExtensions' modules/pact/README.md` prints at least 1.
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` prints nothing.
</acceptance_criteria>
<done>A superuser finds Administrators under Settings → System, lists administrators, opens a preview and runs a plugin-contributed record action; admins without the code get 403; apps without admin controllers boot as before.</done>
</task>
<task type="auto">
<name>Task 2: Administrators form: create and edit administrators with passwords, role and per-user permission overrides, under every save-path privilege rule</name>
<reversibility rating="reversible">Hooks and unexported helpers inside cabana; no published surface beyond the screen's behaviour.</reversibility>
<files>modules/cabana/backend_privileges.go, modules/cabana/backend_users_admin.go, modules/cabana/contracts.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/controllers/users/config_filter.yaml, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
<read_first>modules/cabana/backend_users_admin.go and backend_screens.go (from Task 1), modules/cabana/crud.go (:647-870, :907-960, :1095-1145, :1180-1210, :1354-1420), modules/cabana/http.go (:753-845), modules/cabana/field_permission.go, modules/cabana/relation_field.go (:20-110), modules/cabana/tx_context.go, modules/cabana/auth.go (:33-140, :476-492), modules/cabana/commands.go (:85-170), modules/cabana/account.go (:40-50, :320-345, :470-480), modules/cabana/contracts.go (:200-250), modules/cabana/filter_schema.go (:290-330), modules/cabana/query.go (:480-495), modules/bouncer/context.go, modules/lagoon/validate.go (:355-380), USER/controllers/users_admin_controller.go (:85-155, :168-320, :370-522), USER/controllers/users/config_filter.yaml, USER/models/user/fields.yaml, ../examples/golem15-wintercms-starter/modules/backend/controllers/Users.php, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/PermissionEditor.php (:93-150), docs/backend/users-and-permissions.md (:39-76, :134-160), 19.6.1-RESEARCH.md (Pattern 3 hook table, Pattern 4, Pattern 5, Pitfalls 1, 2, 4, 9), 19.6.1-UI-SPEC.md (C3, C8)</read_first>
<action>Per D-03, D-05, D-06, D-07, D-08, D-09 (demotion path), D-11, D-12 (password set), D-19 and D-20.
backend_privileges.go (new):
- `actsAsSuperuser(p *bouncer.Principal) bool` is `p != nil &amp;&amp; p.IsSuperuser &amp;&amp; (p.Scope == nil || p.Scope.Full)`; every superuser test on these screens uses it (Allows lets a superuser through, and a superuser's scoped personal token must not get superuser-only powers).
- `roleGrantSet(reg *Registry, role BackendUserRole) map[string]bool`: parseGrants(role.Permissions) plus reg.rolePermissions(role.Code), the FindByID merge.
- `effectiveGrants(ctx, db *gorm.DB, reg *Registry, user *BackendUser) (map[string]bool, error)`: loads the role by user.RoleID into its own BackendUserRole value through `db.Session(&amp;gorm.Session{NewDB: true})`, never onto the record being saved (GORM's Save would rewrite role_id from a loaded association, RESEARCH anti-pattern), then applyUserPermissions(grants, user.Permissions).
- `grantsCovered(holder, wanted map[string]bool) bool`: each non-wildcard code in wanted must pass granted(holder, code); a wildcard grant in wanted (ending in ".*" or starting with "*") is covered only when holder holds the same wildcard or a broader prefix wildcard (RESEARCH A3); a wildcard from wanted is never passed to granted or Allows.
- `guardOutranked(ctx, reg, editor *bouncer.Principal, target *BackendUser) error` (D-19): nil for actsAsSuperuser editors and for the editor's own record; otherwise the target's effectiveGrants must be grantsCovered by editor.PermissionGrants, else `&amp;ForbiddenError{Message: "backend::lang.user.outranked"}`.
- `guardLastSuperuser(ctx, target *BackendUser) error` (D-09): nil unless the target is currently a superuser, activated and not deleted; then, through the write transaction from TxFromContext (none is an error), `SELECT pg_advisory_xact_lock(?)` with the fixed int64 `superuserLockKey` (documented beside it), and count other rows with `is_superuser AND is_activated AND id &lt;&gt; ?` (the default scope adds deleted_at IS NULL); zero answers `&amp;ForbiddenError{Message: "backend::lang.user.last_superuser"}`. The caller always holds its row lock first and nothing takes a row lock after the advisory lock, so there is no wait cycle (RESEARCH Pattern 4).
- `revocationCutoff() time.Time` returns `time.Now().UTC().Add(time.Second)` (admin:reset-password's value).
contracts.go: BackendUser gains unexported fields `priorRoleID *uint` and `priorLoaded bool` (GORM and cabana's model reflection skip unexported fields) and `FilterScopes()` returning filterByRole with `FilterScope(name, db, value)` narrowing to `backend_users.role_id = value` (anything else matches nothing, a nil db passes through), as USER models/user.go:112-129 does for groups.
crud.go and http.go, unexported in-package hooks (no pact surface; other controllers' payloads unchanged):
- `recordDataExtender` with `extendRecordData(ctx context.Context, record any, data map[string]any)`, called by projectFullRecord after the scalar projection when the controller implements it.
- `schemaFieldHider` with `hiddenSchemaFields(ctx context.Context) map[string]bool`, applied in formSchema while building `kept` and in listSchema to columns and filters, always into new slices (the cached schema is never mutated).
backend_users_admin.go:
- `extendRecordData`: for actsAsSuperuser principals set data["is_superuser"] to the stored flag (Pitfall 1: the virtual switch must load its value or an untouched save demotes). `hiddenSchemaFields`: is_superuser for every non-superuser (D-03).
- ListExtendQuery and FormExtendQuery: for non-superusers add `Where("backend_users.is_superuser = ?", false)` (qualified, query.go:485-490), so a superuser id is not found for them on show, partials, update, delete and record actions, and never listed (D-03). Task 3 makes both unscoped for the trashed view.
- FormVirtualFields: password, password_confirmation, is_superuser. FormRules(ctx, op): password `required|min:4|confirmed` on create and `nullable|min:4|confirmed` on update (D-11); login and email uniqueness is checked by hand below, never with lagoon's unique rule (Pitfall 4). FilterOptions(scope): for filterByRole every role by name (USER :168-191 pattern).
- AdminRelationLocks(ctx, "role") (D-07): zero lock for superusers; otherwise lock the ids of every role whose roleGrantSet the editor's grants do not cover, Message backend::lang.user.role_ceiling. (Locks, not option filtering, so a target's current above-ceiling role still saves unchanged.)
- PermissionEditorProvider (mode radio, D-06, D-07): AdminPermissionOptions lists the catalog in permissionOrder (Label and Tab from pact.Permission; Comment backend::lang.permissions.manage_users_comment and manage_roles_comment for the two backend codes) with `Locked: !actsAsSuperuser(editor) &amp;&amp; !granted(editor.PermissionGrants, code)`; AdminPermissionValues decodes BackendUser.Permissions JSON into code to 1 or -1 (other values dropped); AdminSetPermissionValues refuses on the editor's own record any next set that differs from the stored one with `&amp;ForbiddenError{Message: "backend::lang.user.self_privileged", Details: {"permissions": [same key]}}` (D-08) and otherwise stores compact JSON, `{}` when empty.
- FormBeforeCreate: virtual is_superuser present and not actsAsSuperuser answers 403 superuser_forbidden with Details is_superuser, whatever its value (D-05); a superuser sets IsSuperuser from it; IsActivated true and ActivatedAt now (admin:create, commands.go:106-115); password: longer than passwordMaxBytes answers 422 on password with accountMessage(ctx, tr, "max.string", "password", max 72), else bouncer.HashPassword(adminBcryptCost(app), plain); then the identity check. No mail is sent (D-13).
- FormBeforeUpdate (the model still holds the stored role_id, is_superuser, is_activated and permissions: protected keys are never filled): record priorRoleID and priorLoaded; guardOutranked for a target that is not the editor (D-19); is_superuser present: not actsAsSuperuser answers 403 superuser_forbidden (D-05); own record with a different value answers 403 self_privileged with Details is_superuser (D-08); stored true and submitted false calls guardLastSuperuser before the flag changes, its refusal carrying Details is_superuser (D-09); then set IsSuperuser. A non-empty password: own record answers 403 `backend::lang.user.own_password` with Details password (D-20); over 72 bytes answers 422; else hash it and set TokensValidAfter to revocationCutoff() (D-11, D-12). Then the identity check.
- Identity check (create and update): one unscoped, case-insensitive count like admin:create (commands.go:96) excluding the record's own id; a held login answers 422 on login with backend::lang.user.login_taken_deleted when only a deleted admin holds it, else login_taken; the same for email with email_taken_deleted and email_taken.
- FormAfterUpdate: on the editor's own record a RoleID different from priorRoleID answers 403 self_privileged with Details role, which rolls the save back (D-08; assignBelongsTo runs after FormBeforeUpdate, Pitfall 2).
YAML: fields.yaml adds password (left) and password_confirmation (right) as type password, context [create, update], comment backend::lang.user.password_comment on password; and the Permissions tab (backend::lang.user.permissions) with is_superuser (switch, label backend::lang.user.superuser, comment backend::lang.user.superuser_comment, span full) and permissions (permissioneditor, mode radio, label backend::lang.user.permission_overrides, comment backend::lang.user.permission_overrides_comment, span full); no trigger key yet (Plan 02). config_list.yaml: toolbar buttons [create], messages create backend::lang.user.new. config_form.yaml: create redirect summercms/cabana/users/preview/:id, redirectClose summercms/cabana/users, messages create backend::lang.user.new. config_filter.yaml (supported types only): role (scope filterByRole, modelClass `Backend\Models\UserRole`, nameFrom name, label backend::lang.user.role_field), is_superuser (switch, column is_superuser, label backend::lang.user.superuser), activated (switch, column is_activated, label backend::lang.user.activated), last_login (daterange, column last_login, label backend::lang.user.last_login); config_list.yaml gets `filter: config_filter.yaml`.
Smoke (append to backend_screens_smoke_test.go): `TestAdministratorsFormSmoke`: the superuser creates an admin with password and confirmation (201, the stored hash verifies with bouncer, is_activated true); GET of the superuser's own record returns data.is_superuser true; a developer-role admin whose role JSON grants backend.manage_users gets 404 on GET of the superuser's id, no is_superuser in the form schema fields, and 403 when updating a non-superuser with is_superuser false in the body; the superuser switching off its own flag answers 403 with the self_privileged text; with a second superuser seeded, demoting it through update answers 200 and its record then reads is_superuser false. (A sequential request cannot reach the last-superuser refusal through the screens, see Decisions; Plan 03 proves each path with a held-lock race.)
Docs in this commit: docs/backend/users-and-permissions.md "Permissions" lists backend.manage_users and backend.manage_roles (granted to no role by default, so only superusers reach the screens until someone grants them); "Managing administrators" gains the Administrators screen (Settings → System, the form, passwords, and the rules D-05, D-07, D-08, D-19, D-20; a password set ends the target's sessions but not their personal API tokens, as admin:reset-password); the My account paragraph (~line 138) now says role, superuser flag, permissions and activation are changed by another administrator on the Administrators screen or with the operator commands. cabana README Features. Commit: `feat(cabana): administrator form with server-side privilege rules`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/cabana/... ./modules/surf/... &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit from vet, the short suites, hello or the docs checker.</fails_when>
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines: a smoke failed, or both printed "--- SKIP" because Docker was unreachable (rerun through sudo -u golem, else record NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'pg_advisory_xact_lock' modules/cabana/backend_privileges.go` prints at least 1 and `grep -c 'func guardLastSuperuser(' modules/cabana/backend_privileges.go` prints 1.
- `grep -c 'func actsAsSuperuser(' modules/cabana/backend_privileges.go` prints 1 and `grep -c 'Scope.Full' modules/cabana/backend_privileges.go` prints at least 1.
- `grep -v '^\s*//' modules/cabana/backend_users_admin.go | grep -c 'Preload("Role")'` prints 0 and the same count on backend_privileges.go prints 0.
- `grep -c 'backend_users.is_superuser = ?' modules/cabana/backend_users_admin.go` prints at least 1.
- `grep -c 'extendRecordData(' modules/cabana/crud.go` prints at least 1 and `grep -c 'hiddenSchemaFields(' modules/cabana/http.go` prints at least 2.
- `grep -cE 'func \([a-z]+ \*?usersAdmin\) FormAfterUpdate\(' modules/cabana/backend_users_admin.go` prints 1.
- `grep -c 'mode: radio' modules/cabana/backendscreens/models/user/fields.yaml` prints 1 and `grep -c 'type: password' modules/cabana/backendscreens/models/user/fields.yaml` prints 2.
- `grep -c 'backend.manage_roles' docs/backend/users-and-permissions.md` prints at least 1 and `grep -ci 'personal API tokens' docs/backend/users-and-permissions.md` prints at least 2.
</acceptance_criteria>
<done>A superuser creates administrators with passwords and edits role, overrides and superuser access; a limited admin is held to D-03, D-05, D-07, D-08, D-19 and D-20 by the server; the last superuser cannot be demoted through the form.</done>
</task>
<task type="auto">
<name>Task 3: Administrators lifecycle: activate, deactivate, delete and restore administrators, with the last active superuser protected on every path and access cut at once</name>
<reversibility rating="reversible">Controller actions and hooks inside cabana; soft delete keeps every row restorable.</reversibility>
<files>modules/cabana/backend_users_admin.go, modules/cabana/contracts.go, modules/cabana/backendscreens/controllers/users/config_list.yaml, modules/cabana/backendscreens/controllers/users/config_form.yaml, modules/cabana/backendscreens/controllers/users/config_filter.yaml, modules/cabana/backendscreens/controllers/users/_hint.htm, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
<read_first>modules/cabana/backend_users_admin.go and backend_privileges.go (from Task 2), modules/cabana/crud.go (:340-380, :586-640, :937-960, :1127-1145), modules/cabana/actions.go, modules/cabana/partial_render.go, modules/cabana/token_guard.go (:90-110), modules/bouncer/jwt.go (:140-175), modules/pact/capabilities.go (:276-330, :720-760), USER/controllers/users_admin_controller.go (:85-129, :524-556, :589-770), USER/controllers/users/_hint.htm, USER/controllers/users/config_list.yaml, USER/controllers/users/config_form.yaml, docs/backend/admin-controllers.md (Bulk actions, Record actions), 19.6.1-UI-SPEC.md (C2, C4, C8, Administrators preview copy), 19.6.1-RESEARCH.md (Pattern 3 hook table rows for delete and actions, Pitfall 7)</read_first>
<action>Per D-08 (self delete and deactivate), D-09, D-10, D-12, D-15 and D-19.
backend_users_admin.go:
- ListExtendQuery and FormExtendQuery start from `db.Unscoped()` and keep the D-03 filter, so deleted admins are listed, opened and restorable (the row write keeps the load scope, crud.go:827-829).
- ListRowStates: pact.RowStateDeleted when DeletedAt.Valid, pact.RowStateDisabled when not activated (no other state).
- FormBeforeDelete (called once per row inside bulk delete, so one refusal rolls back the whole selection): the editor's own record answers 403 with Message backend::lang.user.self_delete (D-08); guardOutranked (D-19); guardLastSuperuser (D-09). FormAfterDelete: set tokens_valid_after to revocationCutoff() on the now soft-deleted row through `tx.Session(&amp;gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()` (D-12). The generic delete stays GORM's soft delete on the tx root; never hard-delete a backend user (D-10; tokens, linked_by and attachments keep their foreign keys). Do not copy the analog's hard-deleting FormAfterDelete.
- AdminRecordActions and AdminBulkActions: activate, deactivate and restore, with UI-SPEC labels, confirms and success messages (user.activate / activate_confirm / activate_selected_confirm / activated / activated_selected, and the same pattern for deactivate and restore); none is Danger. Applies: activate when not deleted and not activated; deactivate when not deleted, activated and not the editor's own record; restore when deleted. Run, for the record and for each bulk row, through cabana.TxFromContext (the analog's builder closures, USER :589-743): deactivate answers 403 self_deactivate on the editor's own record (bulk path), then guardOutranked and guardLastSuperuser, then sets is_activated false and tokens_valid_after revocationCutoff() (D-12); activate runs guardOutranked, then sets is_activated true and activated_at now, no bump; restore runs guardOutranked, then sets deleted_at NULL, no bump. Bulk rows that do not apply are skipped and not counted in Affected; a refused row returns its ForbiddenError and rolls back the selection. Every refusal from a delete or an action is a ForbiddenError with Message set (UI-SPEC C8: the toast shows only the message).
- PartialData(ctx, "hint", record): a view {Kind, Title, Text} by precedence deleted (danger, user.trashed_hint_title and trashed_hint_desc) > deactivated (warning, user.deactivated_hint_title and deactivated_hint_desc) > the editor's own record (warning, user.self_hint_title and self_hint_desc) > nothing (empty Title renders nothing).
contracts.go: BackendUser FilterScopes adds filterTrashed: value hide narrows to `backend_users.deleted_at IS NULL`, only to `IS NOT NULL`, both or none no narrowing (accept the value shapes the analog's scope parser accepts). The controller's FilterOptions serves its two options (hide, only) labelled user.filter_deleted_hide and user.filter_deleted_only.
YAML and partial: _hint.htm copied verbatim from USER/controllers/users/_hint.htm; config_form.yaml preview headerPartial hint, recordActions [activate, deactivate, restore], messages deleteConfirm backend::lang.user.delete_confirm and deleted backend::lang.messages.form.deleted; config_list.yaml toolbar buttons [create, delete], showCheckboxes true, bulkActions [activate, deactivate, restore], messages empty backend::lang.user.list_empty, deleteConfirm backend::lang.user.delete_selected_confirm, deleted backend::lang.messages.list.deleted, rowStateDeleted backend::lang.user.state_deleted, rowStateDisabled backend::lang.user.state_deactivated (no rowStateNegative); config_filter.yaml gets the trashed scope (scope filterTrashed, modelClass `Backend\Models\User`, label backend::lang.user.filter_deleted).
admin:* commands: none clears is_superuser or deactivates today (commands.go:71-168), so D-09 needs no command change; leave commands.go and token_commands.go untouched (SC-1).
Smoke (append): `TestAdministratorsLifecycleSmoke`: the superuser deactivates another admin by record action and that admin's earlier bearer token answers 401 on GET /auth/me; deletes an admin through DELETE, finds the row with row state deleted in the list, restores it with the record action and it lists without the state; a bulk deactivate whose selection holds another admin and the actor's own row answers 403 with the self_deactivate text and leaves both rows activated (the whole selection rolled back).
Docs in this commit: docs/backend/users-and-permissions.md, a lifecycle paragraph (activate, deactivate, soft delete, restore, the trashed filter, the last active superuser rule on every path, revocation: deactivate and delete end the target's sessions and personal API tokens, activate and restore bring their personal tokens back, the admin:* commands never clear the superuser flag); cabana README Features. Commit: `feat(cabana): administrator lifecycle and last-superuser protection`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/cabana/... &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit from vet, the short suite, hello or the docs checker.</fails_when>
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke|TestAdministratorsLifecycleSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>Fewer than 3 top-level "--- PASS: Test" lines: a smoke failed or skipped (Docker unreachable: rerun through sudo -u golem, else NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'guardLastSuperuser(' modules/cabana/backend_users_admin.go` prints at least 3 (update demotion, delete, deactivate).
- `grep -v '^\s*//' modules/cabana/backend_users_admin.go | grep -cE 'Unscoped\(\)\.Delete|Unscoped\(\)\.Where\([^)]*\)\.Delete'` prints 0.
- `grep -c 'revocationCutoff()' modules/cabana/backend_users_admin.go` prints at least 3 (password set, deactivate, delete).
- `grep -c 'bulkActions: \[activate, deactivate, restore\]' modules/cabana/backendscreens/controllers/users/config_list.yaml` prints 1 and `grep -c 'recordActions: \[activate, deactivate, restore\]' modules/cabana/backendscreens/controllers/users/config_form.yaml` prints 1.
- `grep -c 'filterTrashed' modules/cabana/backendscreens/controllers/users/config_filter.yaml` prints 1.
- `cmp modules/cabana/backendscreens/controllers/users/_hint.htm ../sm-grzybyfunkcjonalne-app/plugins/golem15/user/controllers/users/_hint.htm` exits 0.
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go` prints nothing.
</acceptance_criteria>
<done>A superuser deactivates, deletes, restores and reactivates administrators from the preview and the list; access ends at once; the last active superuser survives every route.</done>
</task>
<task type="auto">
<name>Task 4: Roles screen: superusers and holders of backend.manage_roles create and edit roles with the permission editor; system roles keep their code and no role in use is deleted</name>
<reversibility rating="reversible">A cabana controller and YAML; role rows keep the existing schema.</reversibility>
<files>modules/cabana/backend_roles_admin.go, modules/cabana/backend_screens.go, modules/cabana/backendscreens/controllers/roles/config_list.yaml, modules/cabana/backendscreens/controllers/roles/config_form.yaml, modules/cabana/backendscreens/models/userrole/columns.yaml, modules/cabana/backendscreens/models/userrole/fields.yaml, modules/cabana/backend_screens_smoke_test.go, modules/cabana/README.md, docs/backend/users-and-permissions.md</files>
<read_first>modules/cabana/backend_screens.go, backend_users_admin.go and backend_privileges.go (from Tasks 1-3), modules/cabana/contracts.go (:17-28), modules/cabana/registry.go (:440-460), modules/lagoon/backend_admin_migrations.go (:20-70), modules/cabana/commands.go (:185-210 roleIDByCode), modules/phrasebook/translator.go (:150-195), USER/controllers/usergroups_admin_controller.go, USER/controllers/usergroups/config_list.yaml, USER/controllers/usergroups/config_form.yaml, USER/controllers/users_admin_controller.go (:479-536), ../examples/golem15-wintercms-starter/modules/backend/controllers/UserRoles.php, ../examples/golem15-wintercms-starter/modules/backend/models/UserRole.php, ../examples/golem15-wintercms-starter/modules/backend/models/userrole/fields.yaml, 19.6.1-UI-SPEC.md (C1, C5, C8 and the D-17 note), 19.6.1-RESEARCH.md (Pattern 6, Pitfall 5)</read_first>
<action>Per D-02 (Roles tile), D-07 (ceiling on the Roles screen), D-16 and D-17.
backend_roles_admin.go (new): `rolesAdmin{screens *backendScreens}`: ID RolesControllerID, ModelName `Backend\Models\UserRole`, ConfigDir controllers/roles, RequiredPermissions [PermissionManageRoles], NewRecord `&amp;BackendUserRole{}`, FormRecordTitle the role name.
- PermissionEditorProvider (mode checkbox): options are the catalog in permissionOrder with `Locked: !actsAsSuperuser(editor) &amp;&amp; !granted(editor.PermissionGrants, code)`, so a non-superuser can neither add nor remove a permission they lack on any role (D-07); AdminPermissionValues decodes the role's Permissions JSON (value 1 only); AdminSetPermissionValues stores compact JSON, `{}` when empty.
- A system role is `IsSystem` or a role whose stored code has code-declared grants (`len(reg.rolePermissions(code)) &gt; 0`).
- FormBeforeCreate and FormBeforeUpdate: a code already used by another role (exact match, excluding the record's own id) answers 422 on code with backend::lang.user.role.code_taken (the database has only an index on code and roleIDByCode fails on an ambiguous one). FormBeforeUpdate re-reads the stored code and is_system through a fresh session on the tx (code is fillable and Fill has already overwritten it; USER :479-497 pattern); a system role whose code changed answers 422 on code with backend::lang.user.role.code_locked (D-16). is_system is a protected key and never fillable, so roles created here are never system roles.
- FormBeforeDelete: a system role answers 403 with Message backend::lang.user.role.delete_system; otherwise count backend_users holding role_id unscoped (soft-deleted admins still reference the role, the foreign key has no ON DELETE); a count above zero answers 403 with Message set to the translator's Choice of backend::lang.user.role.delete_has_users for that count with name and count params in the request locale (a ForbiddenError carries no params, so the hook translates; phrasebook Translator from the app, towel locale on ctx). Roles have no deleted_at, so the generic delete is a hard delete, which is correct for an unused, non-system role.
backend_screens.go: AdminControllers adds rolesAdmin; Settings adds the link entry Code roles, Controller RolesControllerID, Permissions [PermissionManageRoles], Label backend::lang.user.role.menu_label, Description backend::lang.user.role.menu_description, Category backend::lang.settings.category_system, Icon shield-check, Order 110 (D-02, UI-SPEC C1).
YAML (UI-SPEC C5, shapes from USER/controllers/usergroups/): controllers/roles/config_list.yaml (list models/userrole/columns.yaml, modelClass `Backend\Models\UserRole`, title backend::lang.user.role.list_title, recordUrl summercms/cabana/roles/update/:id, showCheckboxes true, noRecordsMessage backend::lang.user.role.list_empty, toolbar buttons [create, delete] with the search prompt, no bulk actions, messages create backend::lang.user.role.new, deleteConfirm backend::lang.user.role.delete_selected_confirm, deleted backend::lang.messages.list.deleted); controllers/roles/config_form.yaml (name backend::lang.user.role.name, form models/userrole/fields.yaml, create redirect summercms/cabana/roles/update/:id and redirectClose summercms/cabana/roles, update redirect to the update screen and redirectClose to the list, no preview block, messages create user.role.new, update user.role.edit, saved, deleteConfirm user.role.delete_confirm, deleted); models/userrole/columns.yaml (name and code searchable and sortable, description searchable, is_system switch sortable, created_at and updated_at invisible sortable); models/userrole/fields.yaml (name left with comment user.role.name_comment; code right with `preset: {type: slug, field: name}` and comment user.role.code_comment; description textarea size tiny span full; permissions on tab backend::lang.user.permissions, permissioneditor mode checkbox, label user.role.permissions_field, comment user.role.permissions_comment, span full).
Smoke (append): `TestRolesSmoke`: the superuser creates a role (name and code, one permission) and gets 201; assigns it to an admin through the Administrators update; deleting the role answers 403 whose message contains the role name and 1; deleting the seeded developer role answers 403 with the delete_system text; changing developer's code answers 422 on code; GET /settings for the superuser lists administrators before roles.
Docs in this commit: docs/backend/users-and-permissions.md, a Roles paragraph (Settings → System → Roles, system roles and their code, code-declared grants merged at runtime, the D-07 ceiling, the in-use and system-role delete refusals); cabana README Features. Commit: `feat(cabana): Roles screen`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./... &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; scripts/check-admin-openapi.sh --check</automated>
<fails_when>Non-zero exit from vet, the whole short suite, hello, the docs checker, or the OpenAPI drift check (this plan must not change any swag-visible type).</fails_when>
<automated>go test -count=1 -run '^(TestBackendScreensPostgresSmoke|TestAdministratorsFormSmoke|TestAdministratorsLifecycleSmoke|TestRolesSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 4 &amp;&amp; go test -count=1 ./modules/cabana ./modules/surf</automated>
<fails_when>Fewer than 4 top-level "--- PASS: Test" lines (a smoke failed or skipped; Docker unreachable means NOT RUN after a sudo -u golem retry), or the full Docker-backed cabana and surf suites exit non-zero.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '"summercms.cabana.roles"' modules/cabana/backend_screens.go` prints 1 and `grep -c 'Code: *"roles"' modules/cabana/backend_screens.go` prints 1.
- `grep -c 'mode: checkbox' modules/cabana/backendscreens/models/userrole/fields.yaml` prints 1 and `grep -c 'type: slug' modules/cabana/backendscreens/models/userrole/fields.yaml` prints 1.
- `grep -c 'delete_has_users' modules/cabana/backend_roles_admin.go` prints at least 1 and `grep -c 'Choice(' modules/cabana/backend_roles_admin.go` prints at least 1.
- `grep -c 'code_locked' modules/cabana/backend_roles_admin.go` prints at least 1.
- `grep -c 'preview:' modules/cabana/backendscreens/controllers/roles/config_form.yaml` prints 0.
- `git log --format=%h "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` prints nothing.
</acceptance_criteria>
<done>Roles are listed, created, edited and deleted on their own Settings tile; system role codes stay fixed; a role in use or a system role cannot be deleted; a limited editor cannot widen any role.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin SPA / API client → cabana admin API | Request bodies, ids and query filters are untrusted; the principal comes from the JWT or personal token guard |
| admin with backend.manage_users → other admins' accounts | A delegated, non-superuser administrator acts on accounts that may hold more privilege |
| contributing plugin → another controller's screen (D-14) | Plugin code runs inside the target's transaction behind the target's permission |
| concurrent write transactions → last-superuser invariant | Two requests may reduce the superuser set at once |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.6.1-01 | Elevation | generic CRUD fill of is_superuser, role_id, permissions, is_activated, password | high | mitigate | protectedFillKey keeps them unfillable; only usersAdmin hooks, the radio editor provider and the WritableForeignKey role relation write them, each after its check |
| T-19.6.1-02 | Elevation | FormBeforeCreate/Update virtual is_superuser | critical | mitigate | presence check on VirtualFieldsFromContext: a non-superuser sending the key answers 403 superuser_forbidden even when the field is hidden (D-05) |
| T-19.6.1-03 | Elevation | own record: role, superuser, permissions, activation | high | mitigate | priorRoleID snapshot compared in FormAfterUpdate (rollback), AdminSetPermissionValues self refusal, self checks on is_superuser, deactivate and delete (D-08) |
| T-19.6.1-04 | Elevation | permission editors and role picker | high | mitigate | PermissionOption.Locked for codes the editor lacks (stored value kept, change 403) and RelationLockProvider on role ids whose roleGrantSet is not covered (D-07); conservative wildcard rule in grantsCovered |
| T-19.6.1-05 | Elevation | password set, deactivate, delete, actions on a higher-privileged admin | critical | mitigate | guardOutranked on update, delete, record and bulk actions for non-superusers (D-19); D-20 refuses an own-password set without the current password |
| T-19.6.1-06 | Denial of service | demote, deactivate, delete of the last active superuser | high | mitigate | guardLastSuperuser on every reducing path: pg_advisory_xact_lock after the row lock plus a count of other active superusers in the write tx (D-09) |
| T-19.6.1-07 | Information disclosure | list, show, partials, filters for non-superusers | medium | mitigate | ListExtendQuery/FormExtendQuery filter on backend_users.is_superuser (404 by id) and hiddenSchemaFields drops the field, column and filter (D-03) |
| T-19.6.1-08 | Elevation | superuser's scoped personal API token | high | mitigate | actsAsSuperuser requires an unscoped or full token scope for every superuser-only power on these screens |
| T-19.6.1-09 | Elevation | contributed record actions (D-14) | high | mitigate | protect() checks the target's RequiredPermissions before the action's own Permissions; compileContributions validates contributed permissions at boot; the run path is the existing row-locked RecordAction |
| T-19.6.1-10 | Tampering | contributed action names | medium | mitigate | boot error on a name the owner or an earlier contributor registers, on reserved names, nil Run, empty label and unknown controller ID |
| T-19.6.1-11 | Spoofing | sessions after deactivate, delete, password set | high | mitigate | tokens_valid_after = now+1s on all three (D-12); FindByID refuses inactive and deleted users for personal tokens |
| T-19.6.1-12 | Spoofing | personal API tokens after a password set for another admin | medium | accept | same semantics as admin:reset-password, documented; the operator revokes them with admin:token:revoke; revoking would widen the phase (flagged) |
| T-19.6.1-13 | Tampering | unprojected is_superuser switch | high | mitigate | extendRecordData projects the stored flag to superusers so an untouched save never demotes (Pitfall 1) |
| T-19.6.1-14 | Elevation | upgrade of existing installs | medium | mitigate | the two codes are declared with no role grants (D-04); only superusers reach the screens until a grant is made |
| T-19.6.1-15 | Denial of service | role delete with holders | low | mitigate | unscoped holder count refuses with 403 before the FK would fail (D-17) |
| T-19.6.1-16 | Information disclosure | login/email collision messages revealing deleted accounts | low | accept | only holders of backend.manage_users see them, and they can list deleted admins anyway |
| T-19.6.1-17 | Denial of service | oversized passwords | low | mitigate | 72-byte cap before bcrypt, min 4, confirmation (D-11) |
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | this plan installs no package; any install would need the package-legitimacy gate and a blocking human checkpoint |
</threat_model>
<verification>
- After each task: `go vet ./... && go test -short ./modules/cabana/... ./modules/pact/... ./modules/surf/...`, `go -C examples/hello test ./...`, the task's smoke tests with Docker, `go test -run '^TestDocsTree$' ./cmd/summer`, `go run ./cmd/summer docs:build --check`.
- After Task 4: `go test ./modules/cabana ./modules/surf` with Docker, `go test -short ./...`, `scripts/check-admin-openapi.sh --check` (no swag-visible change in this plan).
- SC-1: `git log "$PLAN_BASE"..HEAD -- modules/cabana/commands.go modules/cabana/token_commands.go modules/cabana/account.go` is empty; the existing TestAdmin*, TestAccount* and conformance tests pass unchanged.
- Manual (verify-work UAT, VALIDATION Manual-Only): sign in as a superuser and as a limited admin, open Settings → System → Administrators and Roles, create, edit, deactivate.
</verification>
<success_criteria>
- Both screens work end to end for superusers; every D-03..D-12, D-16, D-17, D-19, D-20 refusal answers with the status and copy of UI-SPEC C8.
- A plugin can contribute a record action to summercms.cabana.users or any other controller; the boot errors fire.
- Admin-less applications and every existing test keep their behaviour; docs and READMEs describe the new surface.
</success_criteria>
<output>
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,293 @@
---
phase: 19.6.1-backend-administrators-screen
plan: 02
type: execute
wave: 2
depends_on: ["19.6.1-01"]
files_modified:
- modules/cabana/field_permission.go
- modules/cabana/relation_field.go
- modules/cabana/crud.go
- modules/cabana/backend_roles_admin.go
- modules/cabana/backend_users_admin.go
- modules/cabana/field_trigger.go
- modules/cabana/form_schema.go
- modules/cabana/schema_types.go
- modules/cabana/admin_openapi.go
- modules/cabana/backendscreens/models/user/fields.yaml
- modules/cabana/record_locks_smoke_test.go
- modules/cabana/field_trigger_smoke_test.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/api/types.ts
- admin/src/components/form/formContext.ts
- admin/src/components/form/control.ts
- admin/src/components/form/formState.ts
- admin/src/components/form/trigger.ts
- admin/src/components/form/FormField.vue
- admin/src/components/form/fields/PermissionEditorField.vue
- admin/src/views/FormView.vue
- admin/src/views/PreviewView.vue
- modules/boardwalk/dist
- docs/backend/forms.md
autonomous: true
requirements: [SC-1, SC-2, D-06, D-08, D-16, D-18, D-21, D-22]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 2
confidence: low
must_haves:
truths:
- "D-21, D-16: on a role that a code-declared pact.Permission.Roles grant attaches to, the Roles form shows each such grant checked, disabled, with the Lock icon and the note backend::lang.permissioneditor.locked_by_code; the save body never carries those codes, the server keeps them, and the save does not answer 403 even when the role's stored JSON lacks them."
- "D-21, D-08: on the editor's own Administrators record every permission override row is record-locked with its stored value (1, -1 or inherit) and the note backend::lang.permissioneditor.locked_self, the save body omits them, and a hand-crafted body that changes one answers 403 on permissions."
- "D-21: show and save responses carry meta.permissionLocks keyed by permissioneditor field name with entries {code, value, note} (note translated in the request locale) only when a record lock applies; the form schema never carries it; responses of controllers without a lock provider are unchanged (omitempty)."
- "D-21, D-06: fields.yaml accepts trigger with exactly action (disable or hide), field and condition (checked or unchecked); a missing or extra sub-key, another value, a controlling field that is not in the same form, is the field itself or is not a switch or checkbox, and a trigger in a repeater row, settings or relation form stop boot naming the controller, the field and the key; the form schema carries it as trigger."
- "D-06: on the Administrators form a superuser opening another superuser sees Permission overrides read-only with the note “Not editable while “Superuser” is on.” between the label and the control, the field is left out of the save body while disabled and the server keeps the stored overrides; switching Superuser off makes it editable at once; a non-superuser, whose schema has no Superuser switch, always gets an editable editor (subject to D-07 locks)."
- "D-21: trigger hide removes the field from rendering, from the save body and from the tab computation (a tab whose fields are all hidden disappears and the first remaining tab becomes active), and a server message for a hidden field appears in the FormErrorBanner as an orphan line; PreviewView applies hide only."
- "D-21: a user edit that flips a trigger to active announces “<field label>: <note>” in the form's polite live region; enabling announces nothing; values typed before a field was disabled stay in the form and return when it is enabled."
- "D-21, D-18: admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated in the same commits that change cabana.FormField and cabana.RecordMeta (scripts/check-admin-openapi.sh --check passes after each), and modules/boardwalk/dist is rebuilt from the committed lockfile and committed (scripts/check-admin-dist.sh passes)."
- "D-21: server enforcement stays authoritative: a client that ignores trigger and record locks gains nothing, because Plan 01's rules and the record-lock save rule decide every write."
- "UI: the trigger note sits above a long permission editor so it is visible without scrolling; a superuser target's Permissions tab shows the Superuser card on, then the read-only overrides editor with the note."
- "UI: record locks render as: none → the plain editor; some → those rows locked with the record's note and the rest editable; all (your own record) → the whole editor read-only; a row both request-locked and record-locked shows the record lock's value and note; on read-only screens (preview, trigger-disabled editor) record locks show the value only, without icon or note."
- "UI: an invalid trigger is a boot error and never reaches the SPA; the trigger and lock notes are static text with no loading state."
artifacts:
- path: "modules/cabana/field_trigger.go"
provides: "FieldTrigger type, trigger compile and boot checks"
contains: "type FieldTrigger struct"
- path: "modules/cabana/field_permission.go"
provides: "PermissionRecordLock, PermissionRecordLockProvider and the record-lock save rule"
contains: "type PermissionRecordLockProvider interface"
- path: "modules/cabana/relation_field.go"
provides: "RecordMeta.PermissionLocks"
contains: "permissionLocks,omitempty"
- path: "admin/src/components/form/trigger.ts"
provides: "trigger evaluation helpers"
contains: "export function applyTriggers"
- path: "admin/src/components/form/fields/PermissionEditorField.vue"
provides: "record-locked rows"
contains: "data-locked-by"
- path: "modules/boardwalk/dist"
provides: "committed admin build with the D-21 SPA support"
key_links:
- from: "modules/cabana/crud.go"
to: "modules/cabana/field_permission.go"
via: "projectFullRecord fills meta.permissionLocks from the controller's PermissionRecordLockProvider"
pattern: "AdminPermissionRecordLocks\\("
- from: "admin/src/views/FormView.vue"
to: "admin/src/components/form/trigger.ts"
via: "the effective field list (tabs, grid, banner names, payload) comes from applyTriggers"
pattern: "applyTriggers\\("
- from: "admin/src/components/form/formState.ts"
to: "admin/src/components/form/control.ts"
via: "editablePayload passes record-locked codes to permissionValues so they are never sent"
pattern: "permissionValues\\("
- from: "modules/cabana/backendscreens/models/user/fields.yaml"
to: "modules/cabana/field_trigger.go"
via: "the permissions field declares trigger disable on is_superuser checked"
pattern: "field: is_superuser"
prohibitions:
- statement: "The SPA never becomes the enforcement point: a trigger-disabled field or a record-locked code is never trusted from the client; the server keeps stored values and decides every write."
status: unresolved
verification: judgment
- statement: "No new npm dependency, design token, colour, font, radius, shadow or icon outside the existing set is added, and no new .vue file is needed."
status: unresolved
verification: judgment
- statement: "Trigger and lock notes render through text interpolation only, never v-html."
status: unresolved
verification: judgment
- statement: "Payloads of controllers that use neither trigger nor record locks are unchanged: the new keys are omitempty and existing keys keep their meaning."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not a user story; MVP mode off): cabana gets WinterCMS's Administrators screens, managed in the admin SPA, with the privilege rules on the server.
This plan's slice (plan 2 of 3, wave 2, D-22 "a short plan for the SPA support in D-21"): the two display features D-21 put in scope, end to end. The tracer carries per-record permission locks from a controller provider through the record response to the permission editor (D-16 code-declared role grants, D-08 your own overrides). Task 2 adds the field `trigger` (D-06: overrides disabled while Superuser is on) and the committed SPA build. Each feature lands with the server type, the swag-generated OpenAPI and `schema.d.ts`, the SPA code and the docs in one commit, so no commit leaves generated files stale.
## Spec-less probe fallback
Skipped for edges (no requirement IDs, no SPEC.md; visible choice). The UI-SPEC "UI Considerations" rows for the trigger and the record locks are lifted into `must_haves.truths` (lines starting "UI:"); the backstop rows for these features are owned by Plan 03, which writes those tests. Prohibitions recalled by hand, descriptor-less.
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
- Partition (D-22): Plan 01 changed no swag-visible type; this plan owns every change to `cabana.FormField` and `cabana.RecordMeta`, so the OpenAPI regeneration happens here, in the same commits as the type changes.
- Record locks are an exported, optional cabana interface next to `PermissionEditorProvider` (`PermissionRecordLockProvider`), so any plugin's permission editor can use them; UI-SPEC C7's recommended payload shape `meta.permissionLocks: {field: [{code, value, note}]}` is kept as is.
- Save rule for a record-locked code: absent from the body keeps the stored value; present with the stored value is accepted; present with another value answers 403 on the field (the existing locked phrase). This works for the old SPA (which sends stored values) and the new one (which sends none), and keeps D-08's "the server refuses" for hand-crafted bodies.
- Locks travel to the permission editor through a provided injection key (`FORM_PERMISSION_LOCKS`) rather than prop threading through FormGrid, FormField and FieldRenderer (UI-SPEC C7 leaves the path to the planner); trigger notes travel the same way (`FORM_TRIGGER_NOTES`).
- Trigger is evaluated only where the SPA renders controller forms (FormView, PreviewView), so the server refuses it in repeater rows, settings forms and relation forms.
- `trigger` supports only the `checked` and `unchecked` conditions (Winter's `value[...]` is out of scope, UI-SPEC C6).
- The dist is rebuilt once, in Task 2, after both features; Task 1 leaves `modules/boardwalk/dist` stale for one commit (the release gate applies before tagging, `.planning/notes/2026-10-07-embedded-admin-release-gate.md`).
## Artifacts this phase produces (this plan)
- cabana exported: `type PermissionRecordLock struct { Code string; Value int; Note string }` (json code, value, note), `type PermissionRecordLockProvider interface { AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error) }`, `RecordMeta.PermissionLocks map[string][]PermissionRecordLock` (json `permissionLocks,omitempty`), `type FieldTrigger struct { Action, Field, Condition string }` (json action, field, condition), `FormField.Trigger *FieldTrigger` (json `trigger,omitempty`).
- cabana unexported: `compileTrigger`, `checkTriggers`; record-lock handling in `applyPermissionValues` and `projectFullRecord`; `AdminPermissionRecordLocks` on `rolesAdmin` and `usersAdmin`.
- fields.yaml key `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`).
- Lang keys `backend::lang.form.trigger_disabled_on`, `form.trigger_disabled_off`, `permissioneditor.locked_by_code`, `permissioneditor.locked_self` (en, pl).
- SPA: `admin/src/components/form/trigger.ts` (`triggerState`, `applyTriggers`), injection keys `FORM_PERMISSION_LOCKS` and `FORM_TRIGGER_NOTES` in formContext.ts, `permissionValues(field, value, locked?)`, `editablePayload(fields, values, mode, locks?)`, type aliases `PermissionRecordLock` and `FieldTrigger` in admin/src/api/types.ts; regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; rebuilt `modules/boardwalk/dist`.
- Smoke tests `TestPermissionRecordLocksSmoke`, `TestFieldTriggerSmoke` (-short).
<objective>
Ship the SPA support D-21 put in scope, server type to rendered control: per-record locked permission values (D-16 code-declared role grants, D-08 your own overrides) and the field `trigger` (D-06), with the OpenAPI and `schema.d.ts` regenerated and the committed admin build refreshed (D-22 plan 02).
Purpose: D-06, D-08, D-16 display rules via D-21; SC-1 (roles shown with the permission editor as they really are); server stays authoritative (SC-2).
Output: two framework commits, each with server, generated OpenAPI, SPA and docs; the second also commits `modules/boardwalk/dist`.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-PATTERNS.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
Concurrency and commits: as in Plan 01 (record `PLAN_BASE=$(git rev-parse HEAD)` before Task 1; check `git status --porcelain -- modules/cabana admin modules/boardwalk docs` before each task; stage by explicit path; no co-author tags; docs with the code; `go vet ./...`, `go test -short ./...` and `go -C examples/hello test ./...` green at every commit). `npm --prefix admin ci` installs from the committed lockfile only; no `npm install` of new packages. When staging `modules/boardwalk/dist`, stage the whole directory path (hashed asset names change) and nothing else from other sessions.
<interfaces>
- From Plan 01 (19.6.1-01-SUMMARY.md has the names as built): `rolesAdmin`, `usersAdmin`, `actsAsSuperuser`, `Registry.permissionOrder`, `Registry.rolePermissions(code)`, `PermissionManageUsers`, `AdministratorsControllerID`, `RolesControllerID`; the users fields.yaml Permissions tab with `is_superuser` (virtual switch, hidden for non-superusers by `hiddenSchemaFields`) and `permissions` (permissioneditor, radio).
- cabana: `PermissionOption`, `PermissionEditorProvider`, `permissionLockedKey`, `localizePermissionOptions`, `liftPermissionValues`, `applyPermissionValues` (the locked comparison loop ~:303-307), `projectPermissionFields` field_permission.go; `RecordMeta{Title, Labels, Actions, Notices}` relation_field.go:95-102; `projectFullRecord` crud.go:647 and the show and save paths that localize meta with `s.tr` (offeredRecordActions, `localizeNotices`); `translateKey(ctx, tr, key)`; `formFieldKeys` form_schema.go:38-50, `compileDependsOn` call form_schema.go:646, `compileDependsOn` / `checkDependsOn` field_dependent.go:23-60 (the compile and sibling-check analog), `FormField` schema_types.go:260-300 (`DependsOn` json `dependsOn,omitempty`); settings forms compile in settings.go `compileSettingWithTypes` (refuses plugin field types: mirror that refusal), repeater row forms in field_repeater.go, relation forms in relation_form.go; swag descriptions of the form schema and record responses in admin_openapi.go (the `preset` / `dependsOn` sentences reflected in admin.json :5261).
- SPA: `FormField`, `PermissionOption`, `RecordMeta` aliases admin/src/api/types.ts:36-51; `permissionValues(field, value)` control.ts:77-90 and `toggleOn` (control.ts, above :60); `editablePayload(fields, values, mode)` formState.ts:53-77 (skips readOnly fields; permissioneditor through permissionValues); FormView.vue: `fields` computed :117 (context filter), `fieldNames` :124, `tabs` :126, `panelFields`, `labels` :80 set from `record.meta.labels` :223, snapshot and save `editablePayload` :208, :226, :398, `announce` :324, provides :314-349, FormErrorBanner :577; PreviewView.vue: `fields` :71, `tabs` :84, meta handling :152, provides :210; formContext.ts injection keys (FORM_VALUES and others, defaults for forms without a provider); FormField.vue (label, FieldRenderer, comment, error markup; describedBy); PermissionEditorField.vue (`disabled(option)` :79, `showsLock(option)` :83, `valueOf`, lock icon and note markup ~:147-166, `t('backend::lang.permissioneditor.locked')`); RelationField.vue locked note markup :326-337 (`Lock :size="14" class="shrink-0 text-muted"`).
- Scripts: `scripts/check-admin-openapi.sh [--check]` (swag v1.16.6 → swagger2openapi → openapi-typescript), `scripts/check-admin-dist.sh` (typecheck, vite build into a temp dir, diff with modules/boardwalk/dist), `npm --prefix admin run test|typecheck|build`.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a role that a plugin's code-declared grant attaches to shows that grant checked and locked with its note on the Roles form, and your own Administrators record shows every override locked, with saves keeping the locked values</name>
<reversibility rating="costly">meta.permissionLocks and PermissionRecordLockProvider become a published payload and interface that plugins and the SPA read; changing them later needs coordinated SPA and plugin changes.</reversibility>
<files>modules/cabana/field_permission.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/backend_roles_admin.go, modules/cabana/backend_users_admin.go, modules/cabana/admin_openapi.go, modules/cabana/record_locks_smoke_test.go, 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/formContext.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, modules/cabana/README.md, docs/backend/forms.md</files>
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md, modules/cabana/field_permission.go, modules/cabana/relation_field.go (:80-110), modules/cabana/crud.go (:500-560, :640-670), modules/cabana/backend_roles_admin.go, modules/cabana/backend_users_admin.go, modules/cabana/admin_openapi.go (record response and form schema descriptions), scripts/check-admin-openapi.sh, admin/src/api/types.ts, admin/src/components/form/formContext.ts, admin/src/components/form/control.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, admin/tests/form/PermissionEditorField.test.ts, docs/backend/forms.md ("Permission editor" :305-373), 19.6.1-UI-SPEC.md (C7, Framework additions copy)</read_first>
<action>Per D-21 for D-16 and D-08.
Server, field_permission.go: exported `PermissionRecordLock{Code string json:"code"; Value int json:"value"; Note string json:"note"}` and the optional `PermissionRecordLockProvider` with `AdminPermissionRecordLocks(ctx context.Context, field string, record any) ([]PermissionRecordLock, error)`, documented: implemented by a controller that also implements PermissionEditorProvider; asked with the request principal (bouncer.User) for a stored record, never on create; Value is what the editor shows (1, -1, or 0 for inherit); Note is a phrase key or text. applyPermissionValues on update: ask the provider for the record's locks; for a record-locked code the submitted value, when present in the body, must equal the stored value, else `&amp;ForbiddenError{Details: {field: [permissionLockedKey]}}`; whether present or not, the stored value is carried into next unchanged (like a non-offered code) and the code is skipped in the request-Locked comparison, so a system role whose code grant is not in its stored JSON saves without a 403.
relation_field.go: `RecordMeta.PermissionLocks map[string][]PermissionRecordLock` json `permissionLocks,omitempty`, with a doc comment (show and save responses; absent when no lock applies; never in the form schema). crud.go: projectFullRecord fills it for each permissioneditor field whose provider returns locks; the Note is translated in the request locale where the show and save responses localize their other meta (with `s.tr`, as for record action labels and notices).
backend_roles_admin.go: AdminPermissionRecordLocks returns, for the role's stored code, every code in `reg.rolePermissions(code)` in permissionOrder with Value 1 and Note backend::lang.permissioneditor.locked_by_code (D-16). backend_users_admin.go: for the editor's own record (principal ID equals the record's), every offered code with Value from the stored overrides (1, -1, else 0) and Note backend::lang.permissioneditor.locked_self; none for other records (D-08 display; AdminSetPermissionValues keeps refusing a change).
Lang (en, pl): permissioneditor.locked_by_code and permissioneditor.locked_self, verbatim from UI-SPEC "Framework additions (D-21)".
admin_openapi.go: extend the record response descriptions with meta.permissionLocks (per permissioneditor field, entries code, value, note; what the editor does with them); run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts and commit both.
SPA (UI-SPEC C7; tokens and markup copied from existing components):
- types.ts: `export type PermissionRecordLock = Schemas['cabana.PermissionRecordLock']`.
- formContext.ts: `FORM_PERMISSION_LOCKS: InjectionKey&lt;Readonly&lt;Ref&lt;Record&lt;string, PermissionRecordLock[]&gt;&gt;&gt;&gt;`; injecting controls default to an empty map.
- control.ts: `permissionValues(field, value, locked?: ReadonlySet&lt;string&gt;)` skips record-locked codes (never sent, like non-offered codes).
- formState.ts: `editablePayload(fields, values, mode, locks?)` passes each permissioneditor field's locked codes to permissionValues; callers without locks are unchanged.
- FormView.vue: keep `record.meta.permissionLocks ?? {}` next to `labels` (reset on every load and after a save), provide it, and pass it to every editablePayload call (dirty snapshot and save). PreviewView.vue: provide it from the record meta.
- PermissionEditorField.vue: inject the locks for `field.name`; a record-locked row shows the lock's value (checkbox checked for 1; radio segment for 1, 0, -1), its control is disabled with aria-disabled="true", the existing Lock 14px follows the label and the existing lock-note line (same id and aria-describedby link) shows the lock's note instead of permissioneditor.locked or the option comment; the row gets `data-locked` and `data-locked-by="record"`; a record lock wins over a request lock (value and note); on a read-only screen record locks show the value only, without icon or note (existing showsLock rule); `set()` refuses record-locked codes.
Smoke (modules/cabana/record_locks_smoke_test.go, package cabana, -short): `TestPermissionRecordLocksSmoke` with an in-test controller implementing both providers (offers a.x and a.y, record-locks a.x at value 1 with a phrase note, stored {a.y: 1}): applyPermissionValues on update with a body without a.x keeps a.x out of next unchanged-stored and stores a.y as submitted; a body sending a.x: 1 when stored lacks it answers ForbiddenError on the field; a body equal to the stored values passes; with a.x also request-Locked and absent from the body there is no 403; projectFullRecord's meta carries permissionLocks for the field and omits the key for a controller without the provider. Keep existing admin tests green (`npm --prefix admin run test`); Plan 03 adds the SPA tests.
Docs in this commit: docs/backend/forms.md "Permission editor": per-record locks (PermissionRecordLockProvider, meta.permissionLocks, the save rule, how the SPA shows them, example: grants a plugin declares for a role); cabana README API rows for PermissionRecordLock, PermissionRecordLockProvider and RecordMeta.PermissionLocks. Commit: `feat(cabana,admin): per-record locked permission values`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/cabana/... &amp;&amp; go test -count=1 -short -run '^TestPermissionRecordLocksSmoke$' -v ./modules/cabana 2&gt;&amp;1 | grep -q -- '^--- PASS: TestPermissionRecordLocksSmoke' &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run typecheck &amp;&amp; npm --prefix admin run test &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit from vet, the short suite, hello, the OpenAPI drift check (committed admin.json or schema.d.ts differ from a fresh generation), the SPA typecheck or vitest run, or the docs checker; or no "--- PASS: TestPermissionRecordLocksSmoke" line.</fails_when>
<automated>go test -count=1 -run '^(TestRolesSmoke|TestAdministratorsFormSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2</automated>
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines: Plan 01's role and form saves broke under the record-lock rule, or Docker was unreachable ("--- SKIP"; rerun through sudo -u golem, else NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'type PermissionRecordLockProvider interface' modules/cabana/field_permission.go` prints 1 and `grep -c 'permissionLocks,omitempty' modules/cabana/relation_field.go` prints 1.
- `grep -c 'AdminPermissionRecordLocks(' modules/cabana/backend_roles_admin.go` prints 1 and the same count on modules/cabana/backend_users_admin.go prints 1.
- `grep -c 'locked_by_code' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1 and `grep -c 'locked_self' modules/phrasebook/backend/lang/en/lang.yaml` prints 1.
- `grep -c 'permissionLocks' admin/src/api/schema.d.ts` prints at least 1 and `grep -c 'cabana.PermissionRecordLock' admin/openapi/admin.json` prints at least 1.
- `grep -c 'data-locked-by' admin/src/components/form/fields/PermissionEditorField.vue` prints at least 1 and `grep -c 'FORM_PERMISSION_LOCKS' admin/src/components/form/formContext.ts` prints 1.
- `grep -c 'v-html' admin/src/components/form/fields/PermissionEditorField.vue` prints 0.
- `git diff --name-only "$PLAN_BASE"..HEAD -- admin/package.json admin/package-lock.json` prints nothing after the commit (`PLAN_BASE` = `git rev-parse HEAD` recorded before Task 1).
</acceptance_criteria>
<done>Opening a system role shows its plugin-declared grants checked and locked with the note; your own record's overrides are locked; saving either keeps the locked values and never 403s on them, while a hand-crafted change is refused.</done>
</task>
<task type="auto">
<name>Task 2: Field trigger: on the Administrators form the permission overrides are read-only with a note while Superuser is on and editable again when it is off; then the committed admin build</name>
<reversibility rating="costly">The fields.yaml trigger key and its schema payload become a contract every plugin's YAML may use; reshaping it later touches plugin YAML and the SPA.</reversibility>
<files>modules/cabana/field_trigger.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/admin_openapi.go, modules/cabana/backendscreens/models/user/fields.yaml, modules/cabana/field_trigger_smoke_test.go, 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/trigger.ts, admin/src/components/form/formContext.ts, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, modules/boardwalk/dist, modules/cabana/README.md, docs/backend/forms.md</files>
<read_first>modules/cabana/form_schema.go (:20-60, :600-700), modules/cabana/field_dependent.go (:1-80), modules/cabana/schema_types.go (:255-300), modules/cabana/settings.go (:70-140), modules/cabana/field_repeater.go (row form compile), modules/cabana/relation_form.go (form compile entry), modules/cabana/admin_openapi.go (form schema description), modules/cabana/backendscreens/models/user/fields.yaml, admin/src/components/form/control.ts (toggleOn), admin/src/components/form/formState.ts, admin/src/components/form/FormField.vue, admin/src/components/form/formContext.ts, admin/src/views/FormView.vue (:110-260, :314-420, :560-600), admin/src/views/PreviewView.vue (:60-120, :200-290), admin/src/components/form/FormErrorBanner.vue, admin/src/components/form/fields/RelationField.vue (:320-340), admin/tests/form/DependentDropdown.test.ts, docs/backend/forms.md (:111-172), scripts/check-admin-dist.sh, .planning/notes/2026-10-07-embedded-admin-release-gate.md, 19.6.1-UI-SPEC.md (C6, Framework additions copy, Typography and Spacing for the note)</read_first>
<action>Per D-21 for D-06.
Server: form_schema.go adds "trigger" to formFieldKeys and calls compileTrigger next to compileDependsOn. field_trigger.go (new): `FieldTrigger{Action string json:"action"; Field string json:"field"; Condition string json:"condition"}`; compileTrigger reads the `trigger` mapping: exactly the sub-keys action (disable or hide), field (an identifier) and condition (checked or unchecked); a missing or extra sub-key or another value is an error naming the key; it sets `FormField.Trigger`. checkTriggers(fields), run after the form's fields compile like checkDependsOn: the named field must be another field of the same form, of type switch or checkbox (a virtual switch such as is_superuser counts). A trigger inside a repeater row form, a settings form or a relation form is a boot error (the SPA evaluates triggers only in FormView and PreviewView); wire the refusal where those forms compile, as settings.go refuses plugin field types. Errors go through bootErr so they name the plugin, controller, file, field and key. schema_types.go: `Trigger *FieldTrigger` json `trigger,omitempty` on FormField with a doc comment. admin_openapi.go: add a sentence on trigger to the form schema description; regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts with scripts/check-admin-openapi.sh. backendscreens/models/user/fields.yaml: the permissions field gets `trigger: {action: disable, field: is_superuser, condition: checked}` (D-06). Lang (en, pl): form.trigger_disabled_on and form.trigger_disabled_off with :field, verbatim from UI-SPEC.
SPA (UI-SPEC C6):
- trigger.ts (new): `triggerState(field, values, present: ReadonlySet&lt;string&gt;): 'active' | 'inactive'` (checked is active when toggleOn(values[controlling]); unchecked is active when it is off; a controlling field missing from `present`, the names of the schema fields that pass this request's filters and context, counts as off) and `applyTriggers(fields, values, labelOf): { fields, notes }`: a hide-active field is removed; a disable-active field becomes a copy with readOnly true; notes maps each disable-active field name to t(form.trigger_disabled_on or _off, {field: controlling label}). Pure, no Vue imports beyond types.
- formContext.ts: `FORM_TRIGGER_NOTES` injection key (default empty map).
- FormView.vue: apply applyTriggers to the context-filtered fields with the whole form's values (not the active tab's) and use the result everywhere the form uses fields: tabs, panelFields, fieldNames for FormErrorBanner (a hidden field's error becomes an orphan banner line), and every editablePayload call (a disabled field is readOnly, so it is not sent; a hidden one is absent), so the server keeps stored values. When the active tab disappears, the first remaining tab becomes active. Provide FORM_TRIGGER_NOTES. When a user edit turns a trigger active, call announce with "&lt;field label&gt;: &lt;note&gt;"; nothing on enable. Values stay in `values` while a field is disabled or hidden.
- FormField.vue: inject the notes; for a field with a note render, between the label and the control, `&lt;p :id="`${controlId}-trigger`" data-trigger-note class="flex items-center gap-2 text-[13px] text-muted"&gt;` holding Info (size 14, class shrink-0, aria-hidden) and the note text by interpolation, and add that id to describedBy.
- PreviewView.vue: apply hide triggers against the record values for fields and tabs; disable has no effect there and no note shows.
- types.ts: `export type FieldTrigger = Schemas['cabana.FieldTrigger']`.
Build: `npm --prefix admin run build`, then stage and commit modules/boardwalk/dist in this commit; scripts/check-admin-dist.sh must pass.
Smoke (modules/cabana/field_trigger_smoke_test.go, package cabana, -short): `TestFieldTriggerSmoke`: a fixture form (fstest.MapFS) with a switch and a field carrying a valid trigger compiles and its localized schema field carries Trigger; one invalid case per rule (unknown action, unknown condition, extra sub-key, missing field, self reference, a text controlling field) fails compilation with an error containing the field name and "trigger"; the compiled Administrators form (through withBackendScreens) carries the trigger on permissions. Keep `npm --prefix admin run test` green; Plan 03 adds the SPA tests.
Docs in this commit: docs/backend/forms.md, a "Triggers" section under field options (the key, the two actions and conditions, the boot errors, that the server stays authoritative and a disabled field is not sent); cabana README Features and the FieldTrigger API row. Commit: `feat(cabana,admin): field trigger and admin build`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./... &amp;&amp; go test -count=1 -short -run '^(TestFieldTriggerSmoke|TestPermissionRecordLocksSmoke|TestBackendScreensSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3 &amp;&amp; go -C examples/hello test -count=1 ./... &amp;&amp; scripts/check-admin-openapi.sh --check &amp;&amp; npm --prefix admin run test &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit from vet, the whole short suite, hello, the OpenAPI drift check, vitest, the dist drift check ("modules/boardwalk/dist is stale"), or the docs checker; or fewer than 3 top-level "--- PASS: Test" lines among the three smokes.</fails_when>
<automated>go test -count=1 ./modules/cabana ./modules/surf</automated>
<fails_when>Non-zero exit of the Docker-backed cabana and surf suites (Plan 01 smokes included); a run where Docker was unreachable shows SKIP lines and is recorded NOT RUN after a sudo -u golem retry.</fails_when>
<human-check>Optional before verify-work: sign in as a superuser, open another superuser's Administrators form, Permissions tab: the overrides editor is read-only with the note; switch Superuser off and it becomes editable; open your own record and a system role and see the locked rows with their notes.</human-check>
</verify>
<acceptance_criteria>
- `grep -c '"trigger": {}' modules/cabana/form_schema.go` prints 1 and `grep -c 'type FieldTrigger struct' modules/cabana/field_trigger.go` prints 1.
- `grep -c 'trigger,omitempty' modules/cabana/schema_types.go` prints 1.
- `grep -c 'field: is_superuser' modules/cabana/backendscreens/models/user/fields.yaml` prints 1.
- `grep -c 'export function applyTriggers' admin/src/components/form/trigger.ts` prints 1 and `grep -c 'applyTriggers(' admin/src/views/FormView.vue` prints at least 1.
- `grep -c 'data-trigger-note' admin/src/components/form/FormField.vue` prints 1 and `grep -c 'v-html' admin/src/components/form/FormField.vue` prints 0.
- `grep -c 'trigger_disabled_on' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
- `git status --porcelain -- modules/boardwalk/dist admin/openapi admin/src/api` prints nothing after the commit.
</acceptance_criteria>
<done>A superuser editing another superuser sees the overrides read-only with the note and editable once Superuser is off; plugins can declare triggers in fields.yaml; the committed admin build carries both D-21 features.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin SPA → cabana save API | The SPA's trigger and lock handling is a display aid; the body it sends is untrusted |
| controller lock provider → record response | Lock notes and values come from plugin code and reach every viewer of the record |
| plugin fields.yaml → boot compiler | A plugin's YAML may declare malformed triggers |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.6.1-18 | Elevation | trigger-disabled permission overrides | medium | mitigate | the disabled field is omitted and the server keeps stored values; superusers bypass overrides anyway; no server rule depends on the trigger |
| T-19.6.1-19 | Elevation | record-locked codes in a hand-crafted save body | high | mitigate | applyPermissionValues refuses a present record-locked value that differs from the stored one (403) and carries the stored value otherwise; D-08's AdminSetPermissionValues refusal stays |
| T-19.6.1-20 | Information disclosure | meta.permissionLocks | low | accept | sent only on show and save responses behind protect() to admins who may open the record; it repeats codes the permission editor already shows |
| T-19.6.1-21 | Tampering | malformed trigger YAML | low | mitigate | compileTrigger and checkTriggers stop boot with errors naming controller, field and key |
| T-19.6.1-22 | Tampering | stale committed SPA build or OpenAPI | medium | mitigate | check-admin-openapi.sh --check and check-admin-dist.sh in this plan's verify; dist rebuilt from the committed lockfile |
| T-19.6.1-23 | Tampering | note text injected into markup | medium | mitigate | notes render by Vue text interpolation only (no v-html); acceptance greps forbid v-html in the touched components |
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is added; `npm --prefix admin ci` installs only the committed lockfile; any new package would need the legitimacy gate and a blocking human checkpoint |
</threat_model>
<verification>
- Task 1: Go short suite and smoke, `scripts/check-admin-openapi.sh --check`, `npm --prefix admin run typecheck` and `test`, docs checker; Plan 01 role and form smokes with Docker.
- Task 2: the same plus `scripts/check-admin-dist.sh`, and the full Docker-backed cabana and surf suites.
- Manual (verify-work UAT): the human-check in Task 2.
</verification>
<success_criteria>
- Record locks and triggers work end to end on both screens; the server behaviour of Plan 01 is unchanged for clients that ignore them.
- Generated OpenAPI, schema.d.ts and the committed dist match fresh builds.
</success_criteria>
<output>
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,316 @@
---
phase: 19.6.1-backend-administrators-screen
plan: 03
type: execute
wave: 3
depends_on: ["19.6.1-01", "19.6.1-02"]
files_modified:
- modules/cabana/export_test.go
- modules/cabana/administrators_harness_test.go
- modules/cabana/administrators_postgres_test.go
- modules/cabana/administrators_lifecycle_postgres_test.go
- modules/cabana/backend_privileges_internal_test.go
- modules/cabana/backend_screens_test.go
- modules/cabana/action_extensions_test.go
- modules/cabana/action_extensions_postgres_test.go
- modules/cabana/roles_postgres_test.go
- modules/cabana/field_trigger_test.go
- modules/cabana/record_locks_test.go
- admin/tests/form/trigger.test.ts
- admin/tests/form/PermissionEditorField.test.ts
- admin/tests/form/formState.test.ts
- admin/tests/form/FormView.test.ts
- admin/tests/form/PreviewView.test.ts
- admin/tests/browser/record-actions-wrap.test.ts
- scripts/check-phase19.6.1.sh
- .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-14, D-15, D-16, D-17, D-18, D-19, D-20, D-21, D-22]
estimate:
tokens: 280000
raw_tokens: 280000
tasks: 4
confidence: low
must_haves:
truths:
- "D-18, D-05: TestAdministratorsSuperuserFlag proves through the assembled HTTP handler that a non-superuser with backend.manage_users sending is_superuser true or false on create or update gets 403 with the superuser_forbidden text on is_superuser and the row is unchanged, and that a superuser grants and removes the flag."
- "D-18: the Postgres tests TestAdministratorsSuperuserHidden (D-03), TestAdministratorsSuperuserProjected (Pitfall 1), TestAdministratorsPermissionCeiling (D-06, D-07), TestAdministratorsRoleCeiling (D-07), TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11), TestAdministratorsOutranked (D-19) and TestAdministratorsOwnPassword (D-20) pass, each asserting status, the localized refusal text and that a refused write changed no row."
- "D-09: TestAdministratorsLastSuperuser races superusers A and B (the only active superusers) on each reducing path (update demotion, record deactivate, bulk deactivate, form delete, bulk delete): a test transaction holds the advisory lock until pg_locks shows both requests waiting, then releases; exactly one request succeeds, the other answers 403 with the last_superuser text, exactly one of A and B stays an active superuser, and no response is a 500 or names a deadlock."
- "D-09: TestAdministratorsLastSuperuserConcurrent runs ten unsynchronized rounds of A demoting B while B demotes A and ends every round with at least one active superuser and no 500."
- "D-10, D-12: TestAdministratorsLifecycle covers activate, deactivate and restore as record and bulk actions, soft delete with the deleted row state and the trashed filter, and that no backend_users row disappears; TestAdministratorsRevocation shows the target's earlier JWT answering 401 after deactivate, delete and a password set by another admin, and tokens_valid_after unchanged after restore and activate."
- "D-14, SC-3: TestActionExtensionBoot (-short) fails boot for an unknown controller, a target without a preview block, a duplicate of the owner's name, a duplicate between two contributors, a reserved name, a nil Run, an empty label and an unknown permission; TestActionExtensionRun offers a contributed action after the owner's only with the target's and the action's permissions and a true Applies, answers 409 when Applies turns false inside the transaction, and runs Run with TxFromContext and the locked record."
- "D-16, D-17, D-07: TestRolesLifecycle (system role code change 422, delete with holders including soft-deleted ones 403 naming the count, system role delete 403, bulk delete rollback) and TestRolesPermissionCeiling (a non-superuser holding backend.manage_roles cannot add or remove a code they lack) pass."
- "D-21: TestFieldTriggerBoot and TestPermissionRecordLocks (-short) and TestRolesCodeDeclaredLocks (Postgres) pass; the SPA tests in admin/tests/form (trigger.test.ts and the extended PermissionEditorField, formState, FormView and PreviewView tests) pass under npm --prefix admin run test, and admin/tests/browser/record-actions-wrap.test.ts passes under npm --prefix admin run test:browser."
- "Activation, D-04: TestBackendScreensActivation shows that plugins without admin controllers activate no admin (no secret read), that with controllers the two settings links, both permission codes and both controllers exist, and that developer and publisher roles hold neither code; examples/hello and the surf admin-prefix tests stay green."
- "D-07: TestRoleCeiling (-short) covers grantsCovered with exact codes, a holder wildcard covering exact codes, a role wildcard covered only by an equal or broader holder wildcard, and an empty role."
- "SC-1: the existing TestAdminCreateCommand, TestAdminResetPasswordCommand, account, auth and OpenAPI conformance tests pass unchanged, and scripts/check-phase19.6.1.sh refuses when any commit since the phase base that touches a phase file also touches modules/cabana/commands.go, token_commands.go or account.go."
- "D-18: scripts/check-phase19.6.1.sh --all exits 0, refusing a stage on any failed, skipped or zero-match test or a missing required name, and reporting NOT RUN for the Docker stage only when no Docker daemon answers (a failure when PHASE1961_REQUIRE_DOCKER=1)."
- { statement: "An SPA browser test renders the preview footer with three owner-style actions and two contributed actions with 40-character labels at 1024px width and asserts the footer wraps without any button overlapping the Edit button (UI-SPEC long-text row for contributed action labels).", verification: backstop }
- { statement: "An SPA unit test shows a radio permission editor with trigger {action: disable, field: is_superuser, condition: checked} read-only with the trigger note while the switch is on, editable when off, and its key absent from editablePayload while disabled.", verification: backstop }
- { statement: "An SPA unit test shows that with is_superuser missing from the schema the triggered editor is editable and its values are sent.", verification: backstop }
- { statement: "An SPA unit test shows that hiding every field of the active tab removes the tab, activates the first remaining tab, and shows a server error for the hidden field as a FormErrorBanner line.", verification: backstop }
- { statement: "An SPA unit test shows a checkbox editor with meta.permissionLocks rendering the locked code checked and disabled with the record note, and permissionValues omitting it from the payload.", verification: backstop }
artifacts:
- path: "modules/cabana/administrators_harness_test.go"
provides: "screens test env: seeded admins, roles, logins, calls with CSRF header, cleanup"
contains: "func newScreensEnv("
- path: "modules/cabana/administrators_lifecycle_postgres_test.go"
provides: "D-09 race harness and lifecycle tests"
contains: "TestAdministratorsLastSuperuser"
- path: "modules/cabana/action_extensions_test.go"
provides: "D-14 boot errors"
contains: "TestActionExtensionBoot"
- path: "admin/tests/form/trigger.test.ts"
provides: "trigger evaluation and FormView payload tests"
contains: "applyTriggers"
- path: "scripts/check-phase19.6.1.sh"
provides: "fail-closed phase gate"
contains: "REQUIRE_TESTS"
key_links:
- from: "modules/cabana/administrators_lifecycle_postgres_test.go"
to: "modules/cabana/export_test.go"
via: "the race holds the same advisory lock key the guard uses"
pattern: "SuperuserLockKey"
- from: "scripts/check-phase19.6.1.sh"
to: "modules/cabana/administrators_postgres_test.go"
via: "the docker stage requires every rule test by name"
pattern: "TestAdministratorsSelfEdit"
- from: "admin/tests/form/trigger.test.ts"
to: "admin/src/components/form/trigger.ts"
via: "unit tests of triggerState and applyTriggers"
pattern: "from '../../src/components/form/trigger'"
prohibitions:
- statement: "No production code changes in this plan except a fix a failing test proves necessary, which is recorded in the SUMMARY as a deviation with its own commit."
status: unresolved
verification: judgment
- statement: "Tests seed and clean up their own uniquely named admins and roles, never depend on another test's rows, and name no consuming application."
status: unresolved
verification: judgment
- statement: "A Docker-backed test that could not run is reported NOT RUN, never counted as passed."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not a user story; MVP mode off): cabana gets WinterCMS's Administrators screens with the privilege rules on the server; SC-3 asks that unit tests cover the privilege rules.
This plan's slice (plan 3 of 3, wave 3, D-18 and D-22: unit tests are the last plan): the tests behind every rule of D-05 to D-09 (with the concurrent last-superuser case), D-12 revocation, the D-14 seam, the Roles rules, D-19, D-20 and the D-21 SPA support, plus the phase gate script. The tracer builds the shared HTTP test env and the first rule test through it; the other tasks add the rule tests by area.
## Spec-less probe fallback
Skipped for edges (no requirement IDs, no SPEC.md; visible choice). The five UI-SPEC "UI Considerations" backstop rows are authored here as `{ statement, verification: backstop }` truths, because this plan writes those tests. Prohibitions recalled by hand, descriptor-less.
## Decisions taken in this plan (recorded assumptions and Claude's discretion)
- The last-superuser refusal is reachable only under concurrency through the screens (Plan 01 Decisions), so each path is proven with a race held on the advisory lock: a test transaction takes the guard's lock, both requests block on it after their row locks (visible as two ungranted advisory rows in pg_locks), the test releases, and the second request sees the first one's commit. `cabana.SuperuserLockKey` is exposed to tests through export_test.go only.
- D-09 tests isolate the superuser set: they record every other active superuser, deactivate them for the test and restore them in t.Cleanup (no cabana test uses t.Parallel); every test seeds uniquely named rows and soft-deletes and demotes them in t.Cleanup.
- Tests that need the compiled registry or unexported helpers live in package cabana (`-short`, no database); HTTP-level tests live in package cabana_test and use `adminGorm`, which skips under -short.
- The footer-wrap backstop needs real layout, so it runs in vitest browser mode (Playwright Chromium, admin/vitest.browser.config.ts), the same setup as admin/tests/browser/richeditor-display.test.ts.
- The gate script copies the fail-closed detectors of scripts/check-phase19.5.sh (`detect_go`, `detect_vitest`, `go_json`, `go_named`) and its consuming-application name check.
## Artifacts this phase produces (this plan)
- Test-only exports in modules/cabana/export_test.go: `SuperuserLockKey`, and a compile helper for the screens registry if the internal tests need it from cabana_test.
- Test env in modules/cabana/administrators_harness_test.go: `newScreensEnv(t)`, `screensEnv.seedAdmin(t, adminSeed)`, `screensEnv.seedRole(t, roleSeed)`, `screensEnv.login(t, login) string`, `screensEnv.call(t, method, rel, body, token)`, `screensEnv.isolateSuperusers(t, keep ...uint)`, the contributor test plugin with `acme_ping`.
- Go tests: TestAdministratorsSuperuserFlag, TestAdministratorsSuperuserHidden, TestAdministratorsSuperuserProjected, TestAdministratorsPermissionCeiling, TestAdministratorsRoleCeiling, TestAdministratorsSelfEdit, TestAdministratorsPasswordAndIdentity, TestAdministratorsOutranked, TestAdministratorsOwnPassword, TestAdministratorsLastSuperuser, TestAdministratorsLastSuperuserConcurrent, TestAdministratorsLifecycle, TestAdministratorsRevocation, TestActionExtensionBoot, TestActionExtensionRun, TestRolesLifecycle, TestRolesPermissionCeiling, TestRolesCodeDeclaredLocks, TestRoleCeiling, TestBackendScreensActivation, TestFieldTriggerBoot, TestPermissionRecordLocks.
- SPA tests: admin/tests/form/trigger.test.ts, additions to PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts, PreviewView.test.ts, and admin/tests/browser/record-actions-wrap.test.ts.
- `scripts/check-phase19.6.1.sh` with stages go, docker, admin, docs, sc1 and `--all`.
- 19.6.1-VALIDATION.md per-task map statuses updated, Wave 0 ticked, `wave_0_complete: true`.
<objective>
Write the unit and Postgres tests for every privilege rule and seam of the phase, the SPA tests for D-21 and the phase gate script, last per the project rule (D-18, D-22 plan 03).
Purpose: SC-2 and SC-3 (unit tests cover the privilege rules), SC-1 regression proof.
Output: test files, the gate script, the filled validation map; test commits separate from any production fix.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-CONTEXT.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-RESEARCH.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md
@.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md
Concurrency and commits: as in Plans 01 and 02. Docker-backed tests skip under -short; run them on this host, through `sudo -u golem` if the Docker socket is denied (CLAUDE.md); a test that could not run is NOT RUN in the SUMMARY. A test that fails because the code is wrong gets a production fix in its own `fix(cabana): ...` commit, named in the SUMMARY as a deviation; never weaken an assertion to pass.
<interfaces>
- From Plans 01 and 02 (their SUMMARYs list the names as built): `cabana.PermissionManageUsers`, `PermissionManageRoles`, `AdministratorsControllerID`, `RolesControllerID`; pact `AdminActionExtension`, `HasAdminActionExtensions`; unexported `withBackendScreens`, `compileActionExtensions`, `grantsCovered`, `guardLastSuperuser`, `superuserLockKey`, `compileTrigger`, `checkTriggers`, `applyPermissionValues`; `cabana.PermissionRecordLock`, `PermissionRecordLockProvider`, `RecordMeta.PermissionLocks`, `FieldTrigger`, `FormField.Trigger`; smoke tests in backend_screens_test.go, backend_screens_smoke_test.go, record_locks_smoke_test.go, field_trigger_smoke_test.go; admin routes `/summercms/cabana/users` and `/summercms/cabana/roles` (list, `{id}`, `{id}/actions/{action}`, `bulk/{action}`, `bulk-delete`, `schema/form`, `schema/list`), `/settings`, `/auth/me`, `/auth/login`.
- Test helpers (package cabana_test): `adminGorm(t)` auth_test.go:321, `insertAdmin` :403, `demoPlugin`, `postJSON`, `adminAPI`, `accessToken`, `adminTestPassword`, `adminTestSecret`, `adminSQL`; env and call pattern phase101_actions_test.go:225-346 (`X-Requested-With: XMLHttpRequest` on writes, `cabana.Envelope[T]`); internal fixtures (package cabana) `contributionPlugin`, `metadataRegistry` settings_link_test.go; internal service handlers called with `bouncer.WithUser` (settings_link_test.go:150-200); export_test.go pattern (`CompiledListSchema`).
- Race tools: `pg_locks` (locktype 'advisory', granted false), `SELECT pg_advisory_xact_lock(?)` in a `gdb.Begin()` transaction, released by Rollback.
- SPA tests: admin/tests/form/PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts, PreviewView.test.ts, RecordActions.test.ts, DependentDropdown.test.ts (sibling evaluation), admin/tests/browser/richeditor-display.test.ts and admin/vitest.browser.config.ts, fixtures under admin/tests/fixtures/.
- Gate analog: scripts/check-phase19.5.sh (`detect_go` :189, `detect_vitest` :239, `go_json` :261, `go_named` :288, `run_docs` :721 with APP_NAMES).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the screens test env seeds admins and roles, signs in, and proves the first privilege rule (D-05) end to end through the assembled admin handler</name>
<reversibility rating="reversible">Test-only code.</reversibility>
<files>modules/cabana/export_test.go, modules/cabana/administrators_harness_test.go, modules/cabana/administrators_postgres_test.go</files>
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-01-SUMMARY.md, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md, modules/cabana/backend_screens_smoke_test.go, modules/cabana/auth_test.go (:300-440), modules/cabana/phase101_actions_test.go (:225-346), modules/cabana/export_test.go, modules/cabana/backend_users_admin.go, modules/cabana/backend_privileges.go, 19.6.1-UI-SPEC.md (C8), 19.6.1-RESEARCH.md (Validation Architecture)</read_first>
<action>Per D-18 and D-05.
export_test.go: `const SuperuserLockKey = superuserLockKey` with a comment (the race tests hold the guard's lock); add a compile helper only if a cabana_test test needs the screens registry.
administrators_harness_test.go (package cabana_test): `newScreensEnv(t)` assembles like newActEnv (compass config with the admin secret and body limits, lagoon.Publish, phrasebook.Activate, surf.Assemble) with demoPlugin plus a contributor plugin (its own permission code acme.screens.ping and a role grant of that code to nothing) that adds record action acme_ping to AdministratorsControllerID and records each Run's RecordID and whether cabana.TxFromContext held a transaction. `seedAdmin(t, adminSeed{Superuser, Activated, Deleted, RoleCode, RolePermissions map, Permissions map, Password})` inserts with a unique login and email from a nanosecond stamp (a seeded role per call when RolePermissions is set, else the developer role), and registers t.Cleanup that sets is_superuser false and soft-deletes the row through SQL. `seedRole(t, roleSeed{Name, Code, Permissions, System})` likewise. `login(t, login) string` posts /auth/login and returns the bearer token. `call(t, method, rel, body, token)` sends JSON with Accept-Language en and X-Requested-With on writes. `isolateSuperusers(t, keep ...uint)` deactivates every other active superuser and restores them in t.Cleanup. Small decoders for the data envelope and the error envelope (code, message, details).
administrators_postgres_test.go: `TestAdministratorsSuperuserFlag` (D-05): a superuser S and a delegated admin L (role grants backend.manage_users); L creating an admin with is_superuser true answers 403, message and details.is_superuser carry the en superuser_forbidden text, and no row with that login exists; L updating a non-superuser N with is_superuser false answers 403 and N's row is byte-for-byte unchanged (compare updated_at and the columns); S creates an admin with is_superuser true (201, row flag true) and then removes it from another superuser through update (200, flag false).</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -short ./modules/cabana &amp;&amp; go test -count=1 -run '^TestAdministratorsSuperuserFlag$' -v ./modules/cabana 2&gt;&amp;1 | grep -q -- '^--- PASS: TestAdministratorsSuperuserFlag'</automated>
<fails_when>Non-zero exit from vet or the short suite, or no "--- PASS: TestAdministratorsSuperuserFlag" line (failed, or "--- SKIP" because Docker was unreachable: rerun through sudo -u golem, else NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'SuperuserLockKey' modules/cabana/export_test.go` prints at least 1.
- `grep -c 'func newScreensEnv(' modules/cabana/administrators_harness_test.go` prints 1 and `grep -cE 'func \([a-z]+ \*screensEnv\) isolateSuperusers\(' modules/cabana/administrators_harness_test.go` prints 1.
- `grep -c 't.Cleanup(' modules/cabana/administrators_harness_test.go` prints at least 2.
- `grep -ciE 'fonoteka|plytarium|grzyby|wavepath' modules/cabana/administrators_harness_test.go modules/cabana/administrators_postgres_test.go` prints 0 for each file.
</acceptance_criteria>
<done>One rule is proven over real HTTP against Postgres, and every later test reuses the same env.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Save-path rule tests: superuser hiding and projection, permission and role ceilings, self-edit, passwords and identity, outranking and own password</name>
<reversibility rating="reversible">Test-only code.</reversibility>
<files>modules/cabana/administrators_postgres_test.go, modules/cabana/backend_privileges_internal_test.go</files>
<read_first>modules/cabana/administrators_harness_test.go, modules/cabana/administrators_postgres_test.go (from Task 1), modules/cabana/backend_users_admin.go, modules/cabana/backend_privileges.go, modules/cabana/contracts.go (granted :214-248), modules/cabana/field_permission.go, modules/cabana/relation_field.go (RelationLock), 19.6.1-UI-SPEC.md (C3, C8), 19.6.1-RESEARCH.md (Pattern 5, Pitfalls 1, 2, 4, 9)</read_first>
<behavior>
- TestAdministratorsSuperuserHidden (D-03): for delegated admin L the list (also with search on the superuser's login and with filters) never contains superuser S; GET, the hint partial and a record action on S's id answer 404; schema/form has no is_superuser field and schema/list no is_superuser column or filter; for S all three exist.
- TestAdministratorsSuperuserProjected (Pitfall 1): S's GET of another superuser returns data.is_superuser true; PUT of exactly the editable payload of that record (as the SPA builds it) answers 200 and the flag stays true.
- TestAdministratorsPermissionCeiling (D-06, D-07): L (holding backend.manage_users and acme.screens.ping only) sees every other catalog code Locked in schema/form; setting a locked code to 1 or -1 on N answers 403 on permissions and N's stored JSON is unchanged; a stored locked value N already has is kept when L saves other fields; allowing or denying acme.screens.ping on N saves and FindByID-equivalent GET /auth/me as N reflects it (the override merged last).
- TestAdministratorsRoleCeiling (D-07): roles whose grants L does not cover are locked in the role field options; assigning one to N answers 403 with the role_ceiling text; N holding an above-ceiling role saves a name change unchanged in role; assigning a covered role saves.
- TestAdministratorsSelfEdit (D-08): L edits own first name, login and email (200); own role change answers 403 self_privileged and the role is unchanged (the after-assignment path); own is_superuser for S answers 403; own permissions change answers 403; own delete answers 403 self_delete; own bulk deactivate answers 403 self_deactivate; the record action deactivate is not offered on your own preview.
- TestAdministratorsPasswordAndIdentity (D-11): create without password, without confirmation or with a mismatch answers 422 on password; 3 characters answers 422; 73 bytes answers 422 with the max text; a valid create stores a bcrypt hash that bouncer verifies; update with a blank password keeps the hash; a login or email equal to another admin's, in another case, or held by a soft-deleted admin answers 422 on that field with login_taken, email_taken or the _deleted variants.
- TestAdministratorsOutranked (D-19): L acting on M whose role grants a code L lacks gets 403 outranked on update (also a password set), delete, record activate, deactivate and restore, and bulk actions, and M's row is unchanged; acting on N with covered grants passes.
- TestAdministratorsOwnPassword (D-20): L or S sending a password for their own record answers 403 own_password on password and the hash is unchanged; another admin's password set by S answers 200.
- TestRoleCeiling (package cabana, -short): grantsCovered with exact codes, a holder wildcard covering exact codes, a role wildcard covered by the same or a broader holder wildcard and not by exact codes, a suffix wildcard, an empty role, a nil holder.
</behavior>
<action>Per D-03, D-05..D-08, D-11, D-19, D-20 and RESEARCH Pitfalls 1, 2, 4, 9. Write the behaviours above as table-driven Go tests where the cases share setup, every refusal asserting the HTTP status, the error code, the en text of the phrase key (read from the translator or compared to the UI-SPEC English value) in message or details as UI-SPEC C8 places it, and the unchanged row (re-read the columns). Seed through the env helpers, one set of rows per test. backend_privileges_internal_test.go holds TestRoleCeiling against grantsCovered directly. These tests describe Plan 01's built behaviour; when one fails, fix the code in a separate fix commit and record it.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -short -run '^TestRoleCeiling$' -v ./modules/cabana 2&gt;&amp;1 | grep -q -- '^--- PASS: TestRoleCeiling'</automated>
<fails_when>Non-zero exit from vet or no "--- PASS: TestRoleCeiling" line.</fails_when>
<automated>go test -count=1 -run '^(TestAdministratorsSuperuserFlag|TestAdministratorsSuperuserHidden|TestAdministratorsSuperuserProjected|TestAdministratorsPermissionCeiling|TestAdministratorsRoleCeiling|TestAdministratorsSelfEdit|TestAdministratorsPasswordAndIdentity|TestAdministratorsOutranked|TestAdministratorsOwnPassword)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 9</automated>
<fails_when>Fewer than 9 top-level "--- PASS: Test" lines: a rule test failed, was renamed, or skipped because Docker was unreachable (rerun through sudo -u golem, else NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE '^func Test(AdministratorsSuperuserHidden|AdministratorsSuperuserProjected|AdministratorsPermissionCeiling|AdministratorsRoleCeiling|AdministratorsSelfEdit|AdministratorsPasswordAndIdentity|AdministratorsOutranked|AdministratorsOwnPassword)\(' modules/cabana/administrators_postgres_test.go` prints 8.
- `grep -c '^func TestRoleCeiling(' modules/cabana/backend_privileges_internal_test.go` prints 1 and `head -1 modules/cabana/backend_privileges_internal_test.go` prints `package cabana`.
- `grep -c 'own_password\|Change your own password' modules/cabana/administrators_postgres_test.go` prints at least 1.
</acceptance_criteria>
<done>Every save-path rule has a test that fails when the rule is removed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Lifecycle, last-superuser race, revocation, seam and Roles tests</name>
<reversibility rating="reversible">Test-only code.</reversibility>
<files>modules/cabana/administrators_lifecycle_postgres_test.go, modules/cabana/action_extensions_test.go, modules/cabana/action_extensions_postgres_test.go, modules/cabana/roles_postgres_test.go, modules/cabana/backend_screens_test.go</files>
<read_first>modules/cabana/administrators_harness_test.go, modules/cabana/backend_screens_test.go, modules/cabana/action_extensions.go, modules/cabana/backend_users_admin.go, modules/cabana/backend_roles_admin.go, modules/cabana/backend_privileges.go, modules/cabana/crud.go (:340-380, :586-640), modules/cabana/settings_link_test.go, modules/cabana/phase121_schema_boot_test.go, modules/cabana/example_actions_test.go, modules/surf/admin_prefix_test.go (:180-200), 19.6.1-RESEARCH.md (Pattern 2, Pattern 4, Pitfalls 5, 7)</read_first>
<behavior>
- TestAdministratorsLastSuperuser (D-09), subtests demote_update, deactivate_record, deactivate_bulk, delete_form, delete_bulk: isolateSuperusers keeping fresh A and B; a test tx holds pg_advisory_xact_lock(cabana.SuperuserLockKey); A acts on B and B acts on A concurrently; poll pg_locks until two ungranted advisory rows exist (fail after 10 s); roll the test tx back; exactly one 2xx and one 403 with the last_superuser text; exactly one of A and B is an active, non-deleted superuser afterwards; no 500 and no "deadlock" in any body.
- TestAdministratorsLastSuperuserConcurrent (D-09): ten rounds of unsynchronized A demotes B and B demotes A; each round ends with at least one active superuser and no 500.
- TestAdministratorsLifecycle (D-10): record and bulk activate, deactivate and restore with Applies (activate offered only when inactive and not deleted, restore only when deleted, deactivate not on yourself); bulk skips rows that do not apply and reports Affected; soft delete keeps the row (count of backend_users unchanged) with row state deleted; the trashed filter options hide and only narrow the list; the preview hint follows deleted > deactivated > own account.
- TestAdministratorsRevocation (D-12): the target's JWT issued before the action answers 401 on /auth/me after deactivate (record and bulk), after delete, and after a password set by S; the target's personal API token stops after deactivate and delete; tokens_valid_after is unchanged after restore and activate.
- TestActionExtensionBoot (package cabana, -short): compile errors for an unknown controller, a target form without preview, a duplicate of the owner's record action, a duplicate between two contributors (message names both plugins), create or delete as a name, a nil Run, an empty label, a permission no plugin registers; a valid contribution lands after the owner's names in declared order across two contributors.
- TestActionExtensionRun (Postgres): acme_ping is offered on an admin's preview only to principals holding both backend.manage_users and acme.screens.ping; a principal with only acme.screens.ping gets 403 from the route; with Applies flipped to false between show and run the POST answers 409; Run saw the RecordID and a transaction.
- TestRolesLifecycle (D-16, D-17): create with a slugged code; duplicate code answers 422 code_taken; changing developer's code answers 422 code_locked; a role a plugin's code-declared grant attaches to counts as system; delete with one active and one soft-deleted holder answers 403 whose message names the role and 2; delete of developer answers 403 delete_system; bulk delete of an unused role plus a held one answers 403 and both rows remain.
- TestRolesPermissionCeiling (D-07): a principal holding backend.manage_roles and acme.screens.ping sees other codes Locked on the Roles form, adding or removing a locked code answers 403 and the role JSON is unchanged, toggling acme.screens.ping saves.
- TestBackendScreensActivation (package cabana, -short): no admin controllers → Activate returns nil routes and nil error without admin.jwt.secret set; with a controller plugin → the registry holds both controllers, both settings links (administrators before roles), both permission codes with no role grants, and no navigation entry from summercms.cabana.
</behavior>
<action>Per D-04, D-07, D-09, D-10, D-12, D-14, D-15, D-16, D-17 and SC-3. Write the behaviours above. The race helper lives in administrators_lifecycle_postgres_test.go and takes a func(actor, target) performing one path, so the five subtests share it; wait on pg_locks with `SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted` every 20 ms. Personal API tokens for the revocation test are minted the way token_postgres_smoke_test.go does. Boot tests reuse the internal fixtures and assert error substrings (controller ID, action name, plugin IDs). When a test fails because the code is wrong, fix the code in a separate fix commit and record it.</action>
<verify>
<automated>go vet ./modules/cabana &amp;&amp; go test -count=1 -short -run '^(TestActionExtensionBoot|TestBackendScreensActivation|TestBackendScreensSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>Non-zero exit from vet, or fewer than 3 top-level "--- PASS: Test" lines among the Docker-free seam and activation tests.</fails_when>
<automated>go test -count=1 -run '^(TestAdministratorsLastSuperuser|TestAdministratorsLastSuperuserConcurrent|TestAdministratorsLifecycle|TestAdministratorsRevocation|TestActionExtensionRun|TestRolesLifecycle|TestRolesPermissionCeiling)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 7</automated>
<fails_when>Fewer than 7 top-level "--- PASS: Test" lines: a test failed (including the race timing out waiting for two lock waiters), was renamed, or skipped because Docker was unreachable (rerun through sudo -u golem, else NOT RUN).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'SuperuserLockKey' modules/cabana/administrators_lifecycle_postgres_test.go` prints at least 1 and `grep -c "locktype = 'advisory'" modules/cabana/administrators_lifecycle_postgres_test.go` prints at least 1.
- `grep -cE 't\.Run\("(demote_update|deactivate_record|deactivate_bulk|delete_form|delete_bulk)"' modules/cabana/administrators_lifecycle_postgres_test.go` prints 5.
- `grep -c '^func TestActionExtensionBoot(' modules/cabana/action_extensions_test.go` prints 1 and `head -1 modules/cabana/action_extensions_test.go` prints `package cabana`.
- `grep -cE '^func Test(RolesLifecycle|RolesPermissionCeiling)\(' modules/cabana/roles_postgres_test.go` prints 2.
- `grep -c 't.Parallel()' modules/cabana/administrators_lifecycle_postgres_test.go` prints 0.
</acceptance_criteria>
<done>The concurrent last-superuser case, every lifecycle path, revocation, the seam and the Roles rules are proven.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: D-21 tests (Go and SPA, including the backstops), the phase gate script and the validation map</name>
<reversibility rating="reversible">Tests, a check script and planning metadata.</reversibility>
<files>modules/cabana/field_trigger_test.go, modules/cabana/record_locks_test.go, modules/cabana/roles_postgres_test.go, admin/tests/form/trigger.test.ts, admin/tests/form/PermissionEditorField.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/browser/record-actions-wrap.test.ts, scripts/check-phase19.6.1.sh, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md</files>
<read_first>.planning/phases/19.6.1-backend-administrators-screen/19.6.1-02-SUMMARY.md, modules/cabana/field_trigger.go, modules/cabana/field_trigger_smoke_test.go, modules/cabana/record_locks_smoke_test.go, modules/cabana/field_permission.go, admin/src/components/form/trigger.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/components/form/formState.ts, admin/src/components/form/control.ts, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue, admin/src/components/form/RecordActions.vue, admin/tests/form/PermissionEditorField.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/browser/richeditor-display.test.ts, admin/vitest.browser.config.ts, scripts/check-phase19.5.sh, .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md, 19.6.1-UI-SPEC.md (C6, C7, UI Considerations backstop rows)</read_first>
<behavior>
- TestFieldTriggerBoot (package cabana, -short): every refusal rule of compileTrigger and checkTriggers (missing or extra sub-key, unknown action or condition, unknown, self or non-toggle controlling field, trigger in a repeater row, a settings form and a relation form), each error naming field and key; valid disable and hide triggers reach the localized schema.
- TestPermissionRecordLocks (package cabana, -short): the save rule (absent keeps stored, equal passes, different answers 403 on the field, request-locked plus record-locked absent passes) for radio and checkbox modes; meta omits permissionLocks without a provider and on create.
- TestRolesCodeDeclaredLocks (Postgres): with a test plugin declaring acme.screens.report for the developer role, GET of developer carries meta.permissionLocks.permissions with acme.screens.report value 1 and the en locked_by_code note; saving developer's description with the SPA payload (which omits the code) answers 200; the delegated admin's own record carries every offered code record-locked with the en locked_self note.
- trigger.test.ts: triggerState for checked and unchecked against true, 1, "1", "true", false and missing values; a controlling field absent from present counts as off; applyTriggers disable makes the field readOnly with the note, hide removes it.
- FormView.test.ts additions: disable backstop (switch on → editor read-only with the data-trigger-note line, key absent from the save body; switch off → editable and sent); absent-controller backstop (schema without is_superuser → editable and sent); hide backstop (all fields of the active tab hidden → tab removed, first tab active, a 422 detail for the hidden field shown in FormErrorBanner); announce text on disable.
- PermissionEditorField.test.ts and formState.test.ts additions: record-lock backstop (checkbox editor with permissionLocks shows the code checked and disabled with data-locked-by="record" and the record note; permissionValues and editablePayload omit it); a radio lock shows the lock value; record lock wins over request lock; read-only screen shows no icon or note.
- PreviewView.test.ts addition: hide trigger removes the field on the preview; disable shows no note.
- record-actions-wrap.test.ts (browser): the footer backstop at 1024px with three owner actions and two contributed actions labelled with 40 characters: the buttons wrap onto more than one row and no button's bounding box intersects the Edit button's.
</behavior>
<action>Per D-21, D-18 and UI-SPEC C6, C7 and the backstop rows. Write the behaviours above (Go tests table-driven; SPA tests with @vue/test-utils and fixtures shaped like admin/tests/fixtures/*.form-schema.json; the browser test mounts RecordActions inside the preview footer markup at a 1024px viewport).
scripts/check-phase19.6.1.sh (executable, `set -euo pipefail`, header comment listing stages like check-phase19.5.sh): copy detect_go, detect_vitest, go_json and go_named from scripts/check-phase19.5.sh. Stages: `go` (go vet ./..., go test -short ./..., go -C examples/hello test ./..., and go_named with -short for TestBackendScreensSmoke, TestBackendScreensActivation, TestActionExtensionBoot, TestRoleCeiling, TestFieldTriggerBoot, TestFieldTriggerSmoke, TestPermissionRecordLocks, TestPermissionRecordLocksSmoke); `docker` (go_named without -short for every Postgres test of Plans 01 and 03 by name, plus go test ./modules/surf; NOT RUN when `docker info` fails, a failure when PHASE1961_REQUIRE_DOCKER=1); `admin` (npm --prefix admin run typecheck, the vitest run through detect_vitest requiring tests/form/trigger.test.ts, PermissionEditorField.test.ts, formState.test.ts, FormView.test.ts and PreviewView.test.ts, npm --prefix admin run test:browser requiring record-actions-wrap.test.ts, scripts/check-admin-openapi.sh --check, scripts/check-admin-dist.sh); `docs` (go_named TestDocsTree in ./cmd/summer, go run ./cmd/summer docs:build --check, and the consuming-application name check of check-phase19.5.sh over modules/ and docs/); `sc1` (PHASE1961_BASE, default the parent of the first commit that added modules/cabana/backend_screens.go: refuse when a commit in BASE..HEAD touches modules/cabana/commands.go, token_commands.go or account.go and also a phase file: action_extensions.go, backend_screens.go, backend_users_admin.go, backend_roles_admin.go, backend_privileges.go, field_trigger.go); `--all` runs go, admin, docs, sc1, then docker. Each stage prints one pass line; any refusal exits non-zero.
19.6.1-VALIDATION.md: the planner already filled the per-task verification map; update its File Exists and Status columns from the runs, correct any test or file name that changed during execution, tick the Wave 0 list with the file names as built, and set `wave_0_complete: true`; leave `status` and `nyquist_compliant` for validate-phase. Commit tests (`test(cabana): ...`, `test(admin): ...`), the script (`chore(scripts): check-phase19.6.1 gate`) and the planning doc (`docs(19.6.1): validation map`) separately.</action>
<verify>
<automated>go test -count=1 -short -run '^(TestFieldTriggerBoot|TestPermissionRecordLocks)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 2 &amp;&amp; npm --prefix admin run test &amp;&amp; npm --prefix admin run test:browser</automated>
<fails_when>Fewer than 2 top-level "--- PASS: Test" lines, or a non-zero exit from the vitest run or the browser run (a failed, missing or skipped SPA test).</fails_when>
<automated>bash scripts/check-phase19.6.1.sh --all</automated>
<fails_when>Non-zero exit or a line starting "refuse:"; a "NOT RUN docker" line is acceptable only when no Docker daemon answers and must be recorded in the SUMMARY.</fails_when>
</verify>
<acceptance_criteria>
- `test -x scripts/check-phase19.6.1.sh` exits 0 and `grep -c 'REQUIRE_TESTS' scripts/check-phase19.6.1.sh` prints at least 1.
- `grep -c 'TestAdministratorsLastSuperuserConcurrent' scripts/check-phase19.6.1.sh` prints at least 1 and `grep -c 'PHASE1961_REQUIRE_DOCKER' scripts/check-phase19.6.1.sh` prints at least 1.
- `grep -c "from '../../src/components/form/trigger'" admin/tests/form/trigger.test.ts` prints 1.
- `grep -c 'data-locked-by' admin/tests/form/PermissionEditorField.test.ts` prints at least 1 and `grep -c '1024' admin/tests/browser/record-actions-wrap.test.ts` prints at least 1.
- `grep -c '^wave_0_complete: true' .planning/phases/19.6.1-backend-administrators-screen/19.6.1-VALIDATION.md` prints 1.
</acceptance_criteria>
<done>D-21 and every backstop have tests, and one script proves the whole phase fail-closed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| test suite → privilege rules | A rule without a test can regress silently |
| shared Postgres container → tests | Rows from one test can change another test's outcome |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.6.1-24 | Repudiation | privilege regressions (D-05..D-09, D-19, D-20) going unnoticed | high | mitigate | one named test per rule asserting status, text and unchanged rows; the gate requires every name and refuses skipped or zero-match runs |
| T-19.6.1-25 | Denial of service | a deadlock in the last-superuser guard hidden by a flaky race | high | mitigate | the held-lock race waits for two pg_locks waiters before releasing, so the interleaving is forced on every path; the unsynchronized rounds assert no 500 or deadlock |
| T-19.6.1-26 | Tampering | cross-test interference through shared superuser rows | medium | mitigate | isolateSuperusers plus per-test unique rows and cleanup; no t.Parallel in the package |
| T-19.6.1-27 | Information disclosure | tests or fixtures naming consuming applications | low | mitigate | the gate's docs stage name check and the acceptance greps |
| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is added; vitest browser mode uses the Playwright already in the lockfile; any new package would need the legitimacy gate and a blocking human checkpoint |
</threat_model>
<verification>
- `go test -short ./modules/cabana` and the named Docker-free tests after every task; the named Postgres tests per task with Docker.
- `npm --prefix admin run test` and `test:browser` after Task 4.
- `bash scripts/check-phase19.6.1.sh --all` as the phase gate (also the input for verify-work).
</verification>
<success_criteria>
- Every decision with a server rule (D-03..D-12, D-14, D-16, D-17, D-19, D-20, D-21) has at least one named test that fails without the rule.
- The concurrent last-superuser case is proven on all five reducing paths.
- The gate script passes on this host with Docker, and reports NOT RUN honestly without it.
</success_criteria>
<output>
Create `.planning/phases/19.6.1-backend-administrators-screen/19.6.1-03-SUMMARY.md` when done
</output>

View File

@@ -59,7 +59,7 @@ The phase is kept short (1-2 plans). Existing cabana payloads and the `admin:*`
- A trashed filter lists deleted admins, and a Restore action brings them back.
- No hard delete, so FK references (tokens, `linked_by`, attachments) stay valid.
- **D-11:** **Passwords are typed in the form.** Password and confirmation fields are required on create and optional on update, and blank means unchanged. The value is bcrypt-hashed like `admin:create`. There is no "send reset link" action and no force-change-on-next-login in this phase.
- **D-12:** **Changes cut access immediately.** Deactivate, soft delete, and setting a password for another admin all bump `tokens_valid_after`, as `admin:reset-password` does. Existing JWTs and personal tokens then stop on the next request; `FindByID` already refuses inactive or deleted users. Restore and activate don't bump it.
- **D-12:** **Changes cut access immediately.** Deactivate, soft delete, and setting a password for another admin all bump `tokens_valid_after`, as `admin:reset-password` does. Existing JWTs then stop on the next request. Deactivate and soft delete also stop personal API tokens, because `FindByID` already refuses inactive or deleted users. A password set for another admin leaves their personal API tokens working, the same as `admin:reset-password` (confirmed 2026-10-22 at plan check). Restore and activate don't bump it.
- **D-13:** **No invite or welcome mail** on create in this phase. Winter's `send_invite` is deferred.
### Record-action seam

View File

@@ -0,0 +1,371 @@
# Phase 19.6.1: Backend Administrators screen - Pattern Map
**Mapped:** 2026-10-21
**Files analyzed:** 24 (new + modified, grouped by plan per D-22)
**Analogs found:** 22 / 24
Primary template for both screens: sm-user-plugin's Users screen. Paths below are relative to the meta repo (`/media/nvme/dev/golem15/summercms.io/summercms/`). The plugin is the git submodule at `sm-grzybyfunkcjonalne-app/plugins/golem15/user`, abbreviated **`USER/`** below. Every file named was checked with `git ls-files` inside that submodule (tracked). Framework paths are relative to `summercms.go/`.
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| **Plan 01: server** | | | | |
| `modules/pact/capabilities.go` (+ `AdminActionExtension`, `HasAdminActionExtensions`) | contract | boot-time registration | `pact.HasAdminRecordActions` / `HasAdminControllers` (same file, lines 183-190, 343-376) | exact |
| `modules/cabana/action_extensions.go` (new, `compileActionExtensions`) | compiler | boot-time merge | `modules/cabana/extension.go` `compileExtension` (lines 60-110) | exact |
| `modules/cabana/http.go` (`Activate`: conditional pseudo-plugin, merge call, `screens.reg = reg`; `formSchema`/list schema per-request field drops) | wiring | request-response | itself, lines 74-130 and 753-790 | exact (in-place) |
| `modules/cabana/backend_screens.go` (new pseudo-plugin) | plugin/provider | boot | `USER/admin.go` (lines 1-38) + `modules/cabana/example_controller_test.go` `BlogPlugin` (lines 115-135) | role-match |
| `modules/cabana/backend_users_admin.go` (new, `summercms.cabana.users`) | controller | CRUD + actions | `USER/controllers/users_admin_controller.go` | exact |
| `modules/cabana/backend_roles_admin.go` (new, `summercms.cabana.roles`) | controller | CRUD | `USER/controllers/users_admin_controller.go` (guardCredentials re-read, FormBeforeDelete, permission provider) | role-match |
| `modules/cabana/backend_privileges.go` (new: `actsAsSuperuser`, `guardLastSuperuser`, grant-subset / outranks helpers) | utility | transform + tx lock | `USER/.../users_admin_controller.go` `guardPrivilegedMember` (193-209); `cabana.Allows`/`granted` in `modules/cabana/contracts.go:214-248` | role-match |
| `modules/cabana/contracts.go` (`BackendUser`/`BackendUserRole` gain `Fillable`, `Rules`, `FilterScopes`, `FilterScope`, unexported snapshot field) | model | CRUD | `Post.Fillable` in `modules/cabana/example_controller_test.go:25-28` | role-match |
| `modules/cabana/crud.go` (unexported projection hook in `projectFullRecord`, record permission locks in meta) | service | request-response | `projectFullRecord` (crud.go:647-670), `FormRecordTitle` hook use at 660-667 | exact (in-place) |
| `modules/cabana/field_permission.go` (skip record-locked codes in the locked comparison; record-lock provider) | service | request-response | itself, lines 55-65 (provider interface), 290-312 (locked check) | exact (in-place) |
| `modules/cabana/relation_field.go` `RecordMeta` (+ `PermissionLocks`) | DTO | response | `RecordMeta` (relation_field.go:95-102) `Labels`/`Notices` | exact |
| `modules/cabana/form_schema.go` + new `field_trigger.go` (`trigger` key, compile + check) | compiler | boot | `modules/cabana/field_dependent.go` `compileDependsOn` / `checkDependsOn` (lines 23-60) | exact |
| `modules/cabana/schema_types.go` `FormField` (+ `Trigger`) | DTO | schema payload | `FormField.DependsOn` (schema_types.go:278-281) | exact |
| `modules/cabana/backendscreens/controllers/users/{config_list,config_form,config_filter}.yaml`, `_hint.htm` | config | — | `USER/controllers/users/{config_list,config_form,config_filter}.yaml`, `_hint.htm` | exact |
| `modules/cabana/backendscreens/models/user/{columns,fields}.yaml` | config | — | `USER/models/user/{columns,fields}.yaml` | exact |
| `modules/cabana/backendscreens/controllers/roles/*.yaml`, `models/userrole/*.yaml` | config | — | `USER/controllers/usergroups/*`, `USER/models/usergroup/*` (no preview block) | exact |
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (+ `user:`, `user.role:`, `permissions:`, `form.trigger_*`, `permissioneditor.locked_*`, `settings.category_system`) | i18n | — | existing `permissioneditor:` block (en lang.yaml:258-264) | exact |
| `modules/cabana/admin_openapi.go` (swag for `trigger`, `meta.permissionLocks`) | docs/annotation | — | existing `dependsOn` description (reflected in `admin/src/api/schema.d.ts:2203, 5778`) | exact |
| `modules/cabana/README.md`, `docs/backend/users-and-permissions.md` (line ~138 paragraph) | docs | — | same files | in-place |
| **Plan 02: SPA (D-21)** | | | | |
| `admin/src/components/form/trigger.ts` (new) | utility | transform | `admin/src/components/form/control.ts` `permissionValues` (lines 77-90) | role-match |
| `admin/src/components/form/formState.ts` `editablePayload` | utility | transform | itself, lines 47-77 | exact (in-place) |
| `admin/src/components/form/FormField.vue` (trigger note), `fields/PermissionEditorField.vue` (record locks), `FormGrid.vue`/`FieldRenderer.vue` (prop threading), `views/FormView.vue`, `views/PreviewView.vue` | component | render | `labels` prop path FormGrid.vue:20,64 → FormField.vue:18,65 → FieldRenderer.vue:22; lock markup PermissionEditorField.vue:79-84,147-166; note markup RelationField.vue:326-337 | exact |
| **Plan 03: tests** | | | | |
| `modules/cabana/backend_*_test.go` (Postgres) | test | — | `modules/cabana/auth_test.go` `adminGorm` (314-360); `modules/cabana/example_actions_test.go`; `USER/admin_privileged_test.go`, `USER/admin_users_test.go` | exact |
| `admin/tests/form/{trigger,PermissionEditorField,formState,FormView,PreviewView}.test.ts` | test | — | existing `admin/tests/form/PermissionEditorField.test.ts`, `formState.test.ts`, `DependentDropdown.test.ts` | exact |
## Pattern Assignments
### `modules/cabana/backend_users_admin.go` (controller, CRUD + actions)
**Analog:** `USER/controllers/users_admin_controller.go`. Note: this file lives in package `cabana`, so drop the `cabana.` prefix on `TxFromContext`, `VirtualFieldsFromContext`, `ForbiddenError`, `RelationLock`, `PermissionOption`, `FieldRelationContract`, `Allows`.
**Controller identity + lazy app accessor** (lines 37-60):
```go
type usersAdminController struct {
app func() *backpack.App
}
func (usersAdminController) ID() string { return "golem15.user.users" } // -> "summercms.cabana.users"
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
func (usersAdminController) ConfigDir() string { return "controllers/users" }
func (usersAdminController) RequiredPermissions() []string { return []string{PermissionAccessUsers} } // -> backend.manage_users
func (usersAdminController) NewRecord() any { return &models.User{} } // -> &BackendUser{}
```
**Unscoped list/form query** (lines 85-97) — extend with D-03 `Where("backend_users.is_superuser = ?", false)` when `!actsAsSuperuser(principal)`:
```go
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil { return db }
return db.Unscoped()
}
```
**Row states** (lines 102-129): copy the loop, keep only `pact.RowStateDeleted` (`DeletedAt.Valid`) and `pact.RowStateDisabled` (`!IsActivated`); no throttle query.
**Virtual fields + rules** (lines 135-155):
```go
func (usersAdminController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "send_invite"} // -> password, password_confirmation, is_superuser
}
func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string {
password := "nullable|" + length + "|confirmed"
if op == "create" { password = "required|" + length + "|confirmed" }
...
}
```
Use `min:4` (Winter) and add the 72-byte check (`passwordMaxBytes`, `modules/cabana/account.go:46-47, 335-336`). Do NOT use lagoon `unique` for login/email (Research Pitfall 4).
**Password storing + revocation** (lines 303-321, 238-247):
```go
values, _ := cabana.VirtualFieldsFromContext(ctx)
plain, _ := values["password"].(string)
if plain == "" { return false, nil }
hashed, err := bouncer.HashPassword(c.configInt(...), plain) // -> bouncer.HashPassword(adminBcryptCost(app), plain)
...
if changed { now := time.Now(); user.TokensValidAfter = &now }
```
Use the `admin:reset-password` cutoff verbatim (`modules/cabana/commands.go:157`): `cutoff := time.Now().UTC().Add(time.Second)`. D-20: own record + non-empty password -> `&ForbiddenError{Message: "backend::lang.user.own_password", Details: map[string]any{"password": []string{...}}}`.
**Create defaults** — copy `admin:create` (`modules/cabana/commands.go:106-115`): `IsActivated: true, ActivatedAt: &now`. Uniqueness query to mirror (commands.go:96), unscoped, excluding own id:
```go
tx.Model(&BackendUser{}).Where("login IN (?, ?) OR lower(email) IN (?, ?)", login, email, strings.ToLower(login), email).Count(&existing)
```
**Re-read stored row in the tx** (lines 479-497) — template for the prior-state snapshot / role-code check:
```go
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
```
**Privilege guard returning 403 with field details** (lines 193-209, 499-522):
```go
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) { return nil }
...
return &cabana.ForbiddenError{Message: message, Details: details}
```
Every delete/action refusal must set `Message` (UI-SPEC C8).
**Writable protected FK relation** (lines 370-380) for `role`:
```go
{Field: "organisation", Kind: "belongsTo", NewRelated: func() any { return &models.Organisation{} },
ForeignKey: "organisation_id", LabelColumn: "name", WritableForeignKey: true},
// -> Field "role", NewRelated &BackendUserRole{}, ForeignKey "role_id"
```
**Role ceiling via relation locks** (lines 402-422):
```go
func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if field != groupsField { return cabana.RelationLock{}, nil } // -> "role"
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, ...) { return cabana.RelationLock{}, nil } // -> actsAsSuperuser
...
return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil // -> backend::lang.user.role_ceiling
}
```
**Permission editor provider** (lines 426-477): copy the three methods; options come from the registry catalog (ordered) with `Locked: !actsAsSuperuser(p) && !granted(p.PermissionGrants, code)`; `AdminSetPermissionValues` refuses own record (D-08) and writes `BackendUser.Permissions` JSON.
**Delete hooks** (lines 524-536): `FormBeforeDelete` is called once per row in bulk delete (rollback of whole selection). Analog's `FormAfterDelete` (538-556) hard-deletes — **do not copy that part**; ours only bumps `tokens_valid_after` via `tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()` (D-10, D-12).
**Bulk actions** (lines 589-627) and **record actions** (lines 665-743): copy the `bulk(...)` / `action(...)` builder closures verbatim, including:
```go
tx, ok := cabana.TxFromContext(ctx)
if !ok { return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") }
```
Names: `activate`, `deactivate`, `restore` (both record and bulk). Deactivate: self -> 403 `user.self_deactivate`, `guardLastSuperuser`, outranks (D-19), bump cutoff.
**Preview hint** (lines 746-770 + `USER/controllers/users/_hint.htm`): copy `usersHintView{Kind, Title, Text}` + `PartialData(ctx, "hint", record)`; precedence deleted > deactivated > own account.
---
### `modules/cabana/backend_roles_admin.go` (controller, CRUD)
**Analog:** same file. Reuse: identity block, `FormRules` (`name` required+unique, `code` uniqueness by hand -> `user.role.code_taken`), re-read pattern (lines 479-497) to detect a `code` change on a system role (`code` is fillable, already written before `FormBeforeUpdate`), `FormBeforeDelete` shape (524-536) returning `ForbiddenError{Message: "backend::lang.user.role.delete_has_users"}` after an **unscoped** `Count` of `backend_users` by `role_id`. Permission provider: checkbox mode; record locks = `reg.rolePermissions(role.Code)` (registry.go:449-460). No preview block, no record actions (YAML from `USER/controllers/usergroups/`).
---
### `modules/cabana/backend_screens.go` (pseudo-plugin)
**Analog:** `USER/admin.go` lines 11-38:
```go
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml controllers/users/_hint.htm models/user/fields.yaml models/user/columns.yaml
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App { if p == nil { return nil }; return p.app })
}
var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
)
```
Embed root: `backendscreens/` (use `fs.Sub` so paths start at `controllers/...`). Add `party.Plugin` methods (`ID() "summercms.cabana"`, `Requires() nil`, no-op `Register`/`Boot`, `modules/party/registry.go:16-21`), `pact.HasSettings`.
**Settings link entries** — analog `pact.SettingsItem` with `Controller` (capabilities.go:570-590; link entry declares no Model/Form/NewModel). Settings item fixture shape: `modules/cabana/example_controller_test.go:121-133`:
```go
return []pact.SettingsItem{{Code: "blog", Label: "...", Description: "...", Category: "...", Icon: "...", Permissions: []string{...}, ...}}
// -> {Code: "administrators", Controller: "summercms.cabana.users", Permissions: []string{"backend.manage_users"},
// Label: "backend::lang.user.menu_label", Description: "backend::lang.user.menu_description",
// Category: "backend::lang.settings.category_system", Icon: "users", Order: 100}
// {Code: "roles", Controller: "summercms.cabana.roles", ..., Icon: "shield-check", Order: 110}
```
Permissions: `pact.Permission{Code, Tab, Label, Roles}` with `Roles` empty (D-04).
---
### `modules/cabana/http.go` `Activate` (wiring)
**Analog:** itself, lines 74-130. Insertion points:
```go
items, err := collectControllers(plugins)
...
if len(items) == 0 { return nil, nil } // line 79: keep; append pseudo-plugin AFTER this check, then re-collect
...
reg, err := compileRegistryWithTypes(items, fieldTypes) // line 103
// NEW: compileActionExtensions(reg, plugins) -- must run before compileContributions (validates action perms)
if err := compileContributions(reg, plugins); err != nil { ... } // line 125
```
Per-request schema filter analog (http.go:767-785): the widget-drop loop builds a fresh `kept` slice — add the D-03 drop of `is_superuser` there through an unexported interface; mirror for the list schema columns and filters.
---
### `modules/cabana/action_extensions.go` (D-14 merge)
**Analog:** `modules/cabana/extension.go` `compileExtension` lines 60-110:
```go
recordActions, err := compileRecordActions(cc.Controller)
if err != nil { return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) }
cc.RecordActions = recordActions
...
if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil {
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)"))
}
for _, name := range cc.Form.recordActions {
action, ok := recordActions[name]
...
if strings.TrimSpace(action.Label) == "" { return bootErr(..., fmt.Errorf("recordActions: action %s needs a label", name)) }
}
```
Merge = append name to `cc.Form.recordActions` and action to `cc.RecordActions`; errors for unknown controller ID, no form/preview, duplicate name, non-identifier, missing `Run`/`Label` (reuse validation of `compileRecordActions`, extension.go:401-427). Consumers then work unchanged: `offeredRecordActions` (crud.go:551-575) and `recordActionOf` (actions.go:242-247) iterate `cc.Form.recordActions`.
### `modules/pact/capabilities.go`
**Analog** (lines 186-190, 372-376):
```go
// HasAdminControllers is implemented by plugins that register admin controllers.
type HasAdminControllers interface { AdminControllers() []AdminController }
// HasAdminRecordActions is implemented by an admin controller that registers ...
type HasAdminRecordActions interface { AdminRecordActions() []AdminRecordAction }
```
New: `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction }` + `type HasAdminActionExtensions interface { AdminActionExtensions() []AdminActionExtension }` (plugin-level). Reuse `AdminRecordAction` (343-354) unchanged.
---
### `modules/cabana/field_trigger.go` + `form_schema.go` + `schema_types.go` (D-21 trigger)
**Analog:** `modules/cabana/field_dependent.go` lines 23-60:
```go
func compileDependsOn(typ string, values map[string]ast.Node, field *FormField) error {
node, ok := values["dependsOn"]
if !ok { return nil }
...
name, err := nodeString(node)
if err != nil || !identifier(name) { return fmt.Errorf("dependsOn %q is not a field name", nodeText(node)) }
field.DependsOn = name
return nil
}
// checkDependsOn(fields []FormField, owner string) error -- byName map, sibling must exist and be scalar
```
Register `"trigger": {}` in `formFieldKeys` (form_schema.go:38-50); call site next to `compileDependsOn` (form_schema.go:646). Payload field next to `DependsOn string \`json:"dependsOn,omitempty"\`` (schema_types.go:278-281). Refuse `trigger` inside repeater forms; controlling field must be `switch`/`checkbox`.
### `modules/cabana/crud.go` / `field_permission.go` / `relation_field.go` (projection + record locks)
**Projection hook analog** — `FormRecordTitle` use inside `projectFullRecord` (crud.go:660-667):
```go
if hook, ok := cc.Controller.(pact.FormRecordTitle); ok && hook != nil {
title, err := hook.FormRecordTitle(ctx, tx, model)
if err != nil { return RecordResult{}, lifecycleFailure(cc, err) }
meta.Title = title
}
```
Use an **unexported** interface (only cabana's controller can implement) to add `data["is_superuser"]` and `meta.PermissionLocks`. Protected key list to leave untouched: crud.go:1407-1417.
**Locked comparison to amend** (field_permission.go:303-307) — skip record-locked codes:
```go
for _, option := range options {
if option.Locked && stored[option.Code] != submitted[option.Code] {
return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}}
}
}
```
**Meta DTO** (relation_field.go:95-102): add `PermissionLocks map[string][]PermissionLock \`json:"permissionLocks,omitempty"\`` beside `Labels`/`Notices`.
---
### Admin YAML (`modules/cabana/backendscreens/...`)
**Analog:** `USER/controllers/users/config_list.yaml`, `config_form.yaml`, `config_filter.yaml` (read in full). Copy structure; substitutions per UI-SPEC C2/C3/C5:
```yaml
list: ~/plugins/golem15/user/models/user/columns.yaml # -> path inside backendscreens FS (check how ~/ resolves for the pseudo-plugin)
recordUrl: golem15/user/users/preview/:id # -> summercms/cabana/users/preview/:id
toolbar: { buttons: [create, delete], search: { prompt: backend::lang.list.search_prompt } }
bulkActions: [activate, deactivate, restore]
messages: { rowStateDeleted: ..., rowStateDisabled: ... } # no rowStateNegative
```
```yaml
preview:
headerPartial: hint
recordActions: [activate, deactivate, restore]
create: { redirect: .../preview/:id, redirectClose: ... }
update: { redirect: .../update/:id, redirectClose: .../preview/:id }
```
Filters: copy `scope` (groups → role, `modelClass`, `nameFrom`, `scope`), `switch` (activated), `daterange` (created_date → last_login). Trashed = `scope` with two `FilterOptions`.
`_hint.htm` copy verbatim:
```html
{{- if .Data.Title -}}
<div class="summer-callout summer-callout--{{ .Data.Kind }}" role="status">
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
</div>
{{- end -}}
```
---
### SPA (Plan 02)
**`formState.ts` `editablePayload`** (lines 53-77) — add a trigger-active skip before the permissioneditor branch:
```ts
for (const field of fields) {
if (field.readOnly || !isRegisteredField(field)) { continue }
...
if (field.type === 'permissioneditor') {
out[field.name] = permissionValues(field, value) // record-locked codes must be skipped here too
continue
}
```
**`control.ts` `permissionValues`** (77-90): pass record locks, skip their codes.
**`PermissionEditorField.vue`** (lines 15-28, 79-84, 147-166, 184-207): extend `isDisabled` (line 79 `readOnly.value || !!option.locked`) and `showsLock` (83-84) with record locks; note line replaces `t('backend::lang.permissioneditor.locked')` with the record note; add `data-locked-by="record"`.
**Prop threading:** copy `labels` path — `FormGrid.vue:20` (`labels?: RecordMeta['labels']`) → `:labels="labels?.[field.name]"` (64) → `FormField.vue:18,65` → `FieldRenderer.vue:22`.
**Trigger note markup:** copy RelationField locked note (RelationField.vue:326-337, `Lock :size="14" class="shrink-0 text-muted"`), swap to `Info`, `flex items-center gap-2 text-[13px] text-muted`.
**Gates:** `scripts/check-admin-openapi.sh`, rebuild + commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`.
---
### Tests (Plan 03)
**Postgres helper:** `modules/cabana/auth_test.go:320-360`:
```go
func adminGorm(t *testing.T) *gorm.DB {
t.Helper()
if testing.Short() { t.Skip("requires testcontainers postgres") }
adminOnce.Do(func() { ... postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("cabana"), ...) })
```
**Privilege test analog:** `USER/admin_privileged_test.go`, `USER/admin_users_test.go`, harness `USER/admin_harness_test.go`. **Record action run-path analog:** `modules/cabana/example_actions_test.go`, `phase121_record_test.go`. **Boot error analog:** `modules/cabana/phase121_schema_boot_test.go`. Also re-run `cd examples/hello && go test ./...` and `go test ./modules/surf` (Activate no-controllers invariant).
**SPA:** `admin/tests/form/PermissionEditorField.test.ts`, `formState.test.ts`, `FormView.test.ts`, `PreviewView.test.ts`, `RecordActions.test.ts`, `DependentDropdown.test.ts` (sibling-field evaluation analog for trigger).
## Shared Patterns
### Write transaction access
**Source:** `USER/controllers/users_admin_controller.go:543-546, 600-603`
**Apply to:** every hook/action that writes or counts (incl. `guardLastSuperuser`)
```go
tx, ok := cabana.TxFromContext(ctx)
if !ok { return errors.New("...: a delete needs its transaction") }
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped()
```
### Refusals
**Source:** `ForbiddenError` use at users_admin_controller.go:208 and field_permission.go:305
**Apply to:** D-05/07/08/09/17/19/20. 403 with `Message` (toast/banner) + `Details` keyed by field (field line). 422 `ValidationError` only for login/email/code field errors.
### Principal + permission checks
**Source:** `bouncer.User(ctx)` + `Allows(principal, codes)` (users_admin_controller.go:194-197; contracts.go:214-248). `actsAsSuperuser` = `p != nil && p.IsSuperuser && (p.Scope == nil || p.Scope.Full)` (bouncer/context.go:16-35). Never pass a wildcard role grant to `granted`.
### Revocation cutoff
**Source:** `modules/cabana/commands.go:157-162` — `time.Now().UTC().Add(time.Second)` into `tokens_valid_after`. Apply to deactivate, delete, password set for another admin; not to restore/activate.
### Lang keys
**Source:** `modules/phrasebook/backend/lang/en/lang.yaml` top-level groups (`permissioneditor:` at 258). Add en + pl in the same commit; `validateMessageKeys` (messages.go:296-315) fails boot on missing `messages.*` keys. CLDR plural maps, not pipe strings.
## No Analog Found
| File / mechanism | Role | Data Flow | Reason |
|---|---|---|---|
| `guardLastSuperuser` with `pg_advisory_xact_lock` (in `backend_privileges.go`) | utility | tx lock | No advisory-lock usage in the repo; use RESEARCH.md "The last-superuser guard (skeleton)" |
| Cross-controller merge semantics (unknown target / duplicate across plugins) | compiler | boot | Only owner-controller actions exist; error wording follows `bootErr` in extension.go |
## Metadata
**Analog search scope:** `summercms.go/modules/{cabana,pact,party,phrasebook}`, `summercms.go/admin/src/components/form`, `summercms.go/admin/tests/form`, `sm-grzybyfunkcjonalne-app/plugins/golem15/user` (tracked submodule).
**Files scanned:** ~30
**Pattern extraction date:** 2026-10-21

View File

@@ -459,27 +459,27 @@ Supported filter types are only `switch` (boolean `column`), `daterange` (time `
| A5 | Record actions only (no bulk/toolbar) in the D-14 seam this phase | Pattern 2 | 19.7 may want a bulk "unlink"; the struct shape keeps it additive |
| A6 | The SPA renders the new controllers with no `admin/` change | Summary | If a gap shows up in UAT (for example the users-count column), it means an admin/ change and a dist rebuild |
## Open Questions
## Open Questions (RESOLVED)
1. **Can a non-superuser manage an admin who outranks them?** (security, not in CONTEXT)
1. **[RESOLVED: D-19 (no outranking).]** **Can a non-superuser manage an admin who outranks them?** (security, not in CONTEXT)
- What we know: D-07 stops granting more than you hold, but a non-superuser with `backend.manage_users` can still set the password of, deactivate, or delete a non-superuser admin whose role grants more than their own. Setting the password is an account takeover, which escalates privilege. sm-user-plugin guards its privileged groups the same way (`guardPrivilegedMember` [VERIFIED: users_admin_controller.go:192-209]).
- Recommendation: add an "outranks" refusal. When the target's effective grants are not a subset of the editor's, a non-superuser gets a 403 on update, delete and actions, using the Pattern 5 helper. This needs user confirmation.
2. **Changing your own password on the Administrators screen.** D-08 doesn't list `password`. My Account requires the current password and mints a new session; this screen would do neither. Recommendation: refuse a password change on your own record here and point to My Account. This needs confirmation.
3. **D-06 "disabled when `is_superuser` is on".** cabana's fields.yaml has no `trigger` key: the accepted keys are listed in `formFieldKeys` and include no `trigger` [VERIFIED: form_schema.go:38-50]. Options:
2. **[RESOLVED: D-20 (own password refused, points to My Account).]** **Changing your own password on the Administrators screen.** D-08 doesn't list `password`. My Account requires the current password and mints a new session; this screen would do neither. Recommendation: refuse a password change on your own record here and point to My Account. This needs confirmation.
3. **[RESOLVED: D-21 chose (b), real `trigger` support in plan 02.]** **D-06 "disabled when `is_superuser` is on".** cabana's fields.yaml has no `trigger` key: the accepted keys are listed in `formFieldKeys` and include no `trigger` [VERIFIED: form_schema.go:38-50]. Options:
- (a) server-side only: superusers bypass `Allows`, so their per-user permissions are inert, and a comment on the field says so;
- (b) add `trigger` support, which changes `admin/` (dist rebuild, possibly the OpenAPI schema).
- Recommendation: (a), recorded as a deviation.
4. **D-16 "code-declared grants shown as locked" per role.** `PermissionOption.Locked` is per request, not per record: `AdminPermissionOptions(ctx, field)` gets no record, and the form schema is fetched without an id [VERIFIED: field_permission.go:61-65, http.go:753-759]. Options:
4. **[RESOLVED: D-21 chose (b), per-record locks in plan 02.]** **D-16 "code-declared grants shown as locked" per role.** `PermissionOption.Locked` is per request, not per record: `AdminPermissionOptions(ctx, field)` gets no record, and the form schema is fetched without an id [VERIFIED: field_permission.go:61-65, http.go:753-759]. Options:
- (a) show the merged values. A save that drops a code-declared grant is refused with a 403 using the locked phrase, or the code-declared grants are stripped from the stored JSON, since they are merged at runtime anyway;
- (b) an optional record-level locks provider whose codes travel in record meta, which changes `admin/` and the OpenAPI schema.
- Recommendation: (a). This needs confirmation, because it softens "shown as locked".
5. **19.7 "set a link" needs input.** Record actions carry no payload [VERIFIED: capabilities.go:360-363].
5. **[RESOLVED: deferred to 19.7; noted in the seam docs.]** **19.7 "set a link" needs input.** Record actions carry no payload [VERIFIED: capabilities.go:360-363].
- Option: `AdminRecordActionInput` could later gain an optional `Payload json.RawMessage`, mirroring `AdminActionInput.Payload` [VERIFIED: capabilities.go:245-251]. That is additive, but it needs SPA input UI.
- Recommendation: keep it out of 19.6.1, design it in 19.7, and note it in the seam docs.
6. **Personal API tokens on password set and on restore** (Pitfall 7).
6. **[RESOLVED: D-12 as amended 2026-10-22: a password set keeps personal tokens; deactivate and delete stop them (plan 01 Decisions).]** **Personal API tokens on password set and on restore** (Pitfall 7).
- Options: revoke the target's personal tokens on deactivate and delete, or accept today's semantics and document them.
- Recommendation: document them, matching `admin:reset-password`. Revoking tokens would widen the phase.
7. **Users count on the Roles list.** Winter shows `users_count`. cabana columns have no count/`valueFrom` key: `columnDocument` holds `Label`, `Searchable`, `Sortable`, `Type`, `Relation`, `Select`, `Invisible`, `Currency`, `CurrencyFrom` [VERIFIED: list_schema.go:62-73]. Recommendation: omit it in this phase.
7. **[RESOLVED: omitted in this phase (plan 01).]** **Users count on the Roles list.** Winter shows `users_count`. cabana columns have no count/`valueFrom` key: `columnDocument` holds `Label`, `Searchable`, `Sortable`, `Type`, `Relation`, `Select`, `Invisible`, `Currency`, `CurrencyFrom` [VERIFIED: list_schema.go:62-73]. Recommendation: omit it in this phase.
## Environment Availability

View File

@@ -42,8 +42,16 @@ Filled by the planner per task; the decision → test map lives in `19.6.1-RESEA
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 19.6.1-01-* | 01 | 1 | D-03..D-17 | T-19.6.1-* | privilege rules enforced server-side | smoke | per plan | ❌ W0 | ⬜ pending |
| 19.6.1-02-* | 02 | 2 | D-03..D-17 | T-19.6.1-* | privilege rules covered by tests | unit/Postgres | per plan | ❌ W0 | ⬜ pending |
| 19.6.1-01-01 | 01 | 1 | D-01, D-02, D-04, D-14, D-15 | T-19.6.1-09, -10, -14 | seam merged before permission validation; contributed actions behind target + own permissions; no default role grant | smoke (-short + Postgres) | `go test -short -run '^TestBackendScreensSmoke$' ./modules/cabana`; `go test -run '^TestBackendScreensPostgresSmoke$' ./modules/cabana`; `go -C examples/hello test ./...` | ❌ W0 | ⬜ pending |
| 19.6.1-01-02 | 01 | 1 | D-03, D-05..D-08, D-11, D-12, D-19, D-20 | T-19.6.1-01..-05, -07, -08, -11, -13, -17 | save-path rules in the save tx; is_superuser projected; hidden for non-superusers | smoke (Postgres) | `go test -run '^TestAdministratorsFormSmoke$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 19.6.1-01-03 | 01 | 1 | D-08, D-09, D-10, D-12, D-19 | T-19.6.1-05, -06, -11, -12 | last-superuser guard under advisory lock; soft delete only; revocation | smoke (Postgres) | `go test -run '^TestAdministratorsLifecycleSmoke$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 19.6.1-01-04 | 01 | 1 | D-02, D-07, D-16, D-17 | T-19.6.1-04, -15 | roles ceiling; system role code locked; in-use delete refused | smoke (Postgres) | `go test -run '^TestRolesSmoke$' ./modules/cabana`; `scripts/check-admin-openapi.sh --check` | ❌ W0 | ⬜ pending |
| 19.6.1-02-01 | 02 | 2 | D-21 (D-16, D-08) | T-19.6.1-19, -20, -22, -23 | record-locked codes kept server-side; changed value 403 | smoke (-short) + vitest | `go test -short -run '^TestPermissionRecordLocksSmoke$' ./modules/cabana`; `scripts/check-admin-openapi.sh --check`; `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
| 19.6.1-02-02 | 02 | 2 | D-21 (D-06) | T-19.6.1-18, -21, -22, -23 | trigger boot errors; disabled field not sent; dist matches build | smoke (-short) + vitest + dist | `go test -short -run '^TestFieldTriggerSmoke$' ./modules/cabana`; `npm --prefix admin run test`; `scripts/check-admin-dist.sh` | ❌ W0 | ⬜ pending |
| 19.6.1-03-01 | 03 | 3 | D-05, D-18 | T-19.6.1-24, -26 | harness + first rule over HTTP | Postgres | `go test -run '^TestAdministratorsSuperuserFlag$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 19.6.1-03-02 | 03 | 3 | D-03, D-06, D-07, D-08, D-11, D-19, D-20 | T-19.6.1-24 | each save-path rule has a failing-without-rule test | unit (-short) + Postgres | `go test -short -run '^TestRoleCeiling$' ./modules/cabana`; `go test -run '^TestAdministrators(SuperuserHidden\|SuperuserProjected\|PermissionCeiling\|RoleCeiling\|SelfEdit\|PasswordAndIdentity\|Outranked\|OwnPassword)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 19.6.1-03-03 | 03 | 3 | D-04, D-09, D-10, D-12, D-14, D-16, D-17 | T-19.6.1-24, -25, -26 | held-lock race on 5 paths; revocation; seam boot and run; roles | unit (-short) + Postgres | `go test -short -run '^(TestActionExtensionBoot\|TestBackendScreensActivation)$' ./modules/cabana`; `go test -run '^(TestAdministratorsLastSuperuser\|TestAdministratorsLastSuperuserConcurrent\|TestAdministratorsLifecycle\|TestAdministratorsRevocation\|TestActionExtensionRun\|TestRolesLifecycle\|TestRolesPermissionCeiling)$' ./modules/cabana` | ❌ W0 | ⬜ pending |
| 19.6.1-03-04 | 03 | 3 | D-21, D-18 | T-19.6.1-24, -27 | D-21 Go + SPA tests incl. backstops; fail-closed gate | unit + vitest + browser + gate | `go test -short -run '^(TestFieldTriggerBoot\|TestPermissionRecordLocks)$' ./modules/cabana`; `npm --prefix admin run test`; `npm --prefix admin run test:browser`; `bash scripts/check-phase19.6.1.sh --all` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -51,12 +59,19 @@ Filled by the planner per task; the decision → test map lives in `19.6.1-RESEA
## Wave 0 Requirements
- [ ] `modules/cabana/backend_screens_test.go` — activation and schema (`-short`)
- [ ] `modules/cabana/action_extensions_test.go` — D-14 boot and run
- [ ] `modules/cabana/backend_screens_test.go` — activation and schema (`-short`; smoke in plan 01, `TestBackendScreensActivation` in plan 03)
- [ ] `modules/cabana/backend_screens_smoke_test.go` — plan 01 Postgres smokes
- [ ] `modules/cabana/record_locks_smoke_test.go`, `modules/cabana/field_trigger_smoke_test.go` — plan 02 smokes (`-short`)
- [ ] `modules/cabana/action_extensions_test.go` (boot, `-short`) and `modules/cabana/action_extensions_postgres_test.go` (run) — D-14
- [ ] `modules/cabana/backend_privileges_internal_test.go` — subset/ceiling helpers (`package cabana`)
- [ ] `modules/cabana/administrators_postgres_test.go` — D-03..D-12 at HTTP level
- [ ] `modules/cabana/roles_postgres_test.go` — D-16/D-17
- [ ] superuser-capable `insertAdmin` helper
- [ ] `modules/cabana/administrators_harness_test.go` — superuser-capable seeding, roles, login, `isolateSuperusers`
- [ ] `modules/cabana/administrators_postgres_test.go` — D-03, D-05..D-08, D-11, D-19, D-20 at HTTP level
- [ ] `modules/cabana/administrators_lifecycle_postgres_test.go` — D-09 held-lock race, D-10, D-12
- [ ] `modules/cabana/roles_postgres_test.go` — D-07 (roles), D-16, D-17, D-21 code-declared locks
- [ ] `modules/cabana/field_trigger_test.go`, `modules/cabana/record_locks_test.go` — D-21 (`-short`)
- [ ] `admin/tests/form/trigger.test.ts` + additions to PermissionEditorField, formState, FormView, PreviewView tests; `admin/tests/browser/record-actions-wrap.test.ts`
- [ ] `scripts/check-phase19.6.1.sh` — phase gate
- [ ] `modules/cabana/export_test.go` — `SuperuserLockKey`
---

View File

@@ -0,0 +1 @@
No external API integration: the phase adds cabana's own Administrators and Roles admin screens, a framework-internal pact capability and SPA support for its own admin JSON API; no third-party API, SDK or service is integrated.