docs(12.1-04): complete groups field, User Groups and Organisations plan

This commit is contained in:
Jakub Zych
2026-10-05 14:09:34 +02:00
parent fcc86ac45e
commit 3f4a01ddf8
3 changed files with 419 additions and 8 deletions

View File

@@ -0,0 +1,409 @@
---
phase: 12.1-user-plugin-admin-screens
plan: 04
subsystem: admin
tags: [sm-user-plugin, cabana, pact, admin, user-groups, organisations, privileged-groups, relation-lock, relation-manager, parity]
requires:
- phase: 12.1-user-plugin-admin-screens
provides: "plans 01 to 03: framework contracts at v0.1.3 (relation locks, ForbiddenError, permissioneditor, preset, hasMany relation manager), the Users screen, classes/privileged.go, the config key golem15.user.privileged_groups, the plugin admin test harness"
provides:
- "user form `groups` relation field, the only writer of users_groups, with AdminRelationLocks on the privileged groups"
- "User Groups admin screen: controller golem15.user.usergroups (list with users_count, form with the checkbox permission editor, guarded delete)"
- "Organisations admin screen: controller golem15.user.organisations (list, form with avatar, members relation manager)"
- "models.UsersGroup, models.Slugify, PermissionSet.Text, UserGroup.Fillable/Rules/UsersCount, Organisation.Fillable/Rules/MorphName/AttachRelations/BeforeValidate/Members"
- "controllers.PermissionAccessGroups; navigation side items usergroups and organisations"
- "fonoteka.go: allow-list entry for golem15_user_frontend_permissions, the submodule pointer at the plugin head, and the application's fixed test lists brought up to date"
affects: [12.1-05, fonoteka.go application tests, sm-user-plugin host applications]
# commits/plan_head_* are measured in summercms.go (the pinned root), where this
# plan changed planning docs only. The code is in two sibling repositories; their
# measured values are the plugin_* and app_* keys.
actuals:
tokens: 33397 # chars/4 over the realized diffs: plugin 5805c63..4693155 (31704) plus fonoteka.go d1abcab..35a727f (1693)
tasks: 4
commits: 0
plan_head_before: fcc86ac45e1011c9bcb243cac965c8af44311ea1
plan_head_after: fcc86ac45e1011c9bcb243cac965c8af44311ea1
plugin_repo: sm-user-plugin (../fonoteka.go/plugins/golem15/user)
plugin_commits: 4 # git -C <plugin> rev-list --count 5805c63..HEAD
plugin_head_before: 5805c6347f6a607287e7e47431419b18c0e0c662
plugin_head_after: 469315510809f703fc315aceaa9ef902794a0237
app_repo: fonoteka.go (../fonoteka.go)
app_commits: 3 # git -C ../fonoteka.go rev-list --count d1abcab..HEAD
app_head_before: d1abcab
app_head_after: 35a727f1ddfde0a443cf203193bf3705fe3158a1
tech-stack:
added: []
patterns:
- "A relation field whose choices carry privilege is guarded by cabana.RelationLockProvider on the controller; the lock ids are read per request through the write's transaction"
- "A rule the rule engine does not know (regex, alpha_dash) is checked in the Form before-hooks and answered as a 422 on the field"
- "A list count column is a read-only model field filled by a scalar subquery in ListExtendQuery, with two select arguments so the list's count stays COUNT(*)"
- "appAccess: new admin controllers hold one lazy accessor type for the app, pool, config and translator"
key-files:
created:
- ../fonoteka.go/plugins/golem15/user/models/users_group.go
- ../fonoteka.go/plugins/golem15/user/models/slug.go
- ../fonoteka.go/plugins/golem15/user/controllers/app_access.go
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_form.yaml
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_list.yaml
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_form.yaml
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml
- ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml
- ../fonoteka.go/plugins/golem15/user/models/usergroup/columns.yaml
- ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml
- ../fonoteka.go/plugins/golem15/user/models/organisation/columns.yaml
- ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go
- ../fonoteka.go/plugins/golem15/user/admin_groups_test.go
- ../fonoteka.go/plugins/golem15/user/admin_organisations_test.go
modified:
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go
- ../fonoteka.go/plugins/golem15/user/models/user_group.go
- ../fonoteka.go/plugins/golem15/user/models/organisation.go
- ../fonoteka.go/plugins/golem15/user/models/permission_set.go
- ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml
- ../fonoteka.go/plugins/golem15/user/admin.go
- ../fonoteka.go/plugins/golem15/user/admin_navigation.go
- ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/user/README.md
- ../fonoteka.go/plugins/golem15/user/admin_users_test.go
- ../fonoteka.go/parity/schema_diff_test.go
- ../fonoteka.go/parity/migrate_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_metadata_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/hidden_marshal_test.go
key-decisions:
- "Organisation gained the Go relation field Members (not a column): the framework's relation manager refuses a relation that is not a field of the owner model"
- "A user who already belongs to an organisation cannot be linked to another one through the members manager: the framework offers and accepts only users without an organisation. The plan expected such a user to move"
- "The format refusals of a group code and an organisation slug are plugin phrase keys (group.code_invalid, organisation.slug_invalid), because the framework has no phrase for regex or alpha_dash"
- "The organisation form uses the framework's default delete confirm; the UI-SPEC names no organisation delete text and I added none"
- "models.ParsePermissionSet keeps its plan 03 signature (raw []byte); only PermissionSet.Text is new"
- "The application's stale test lists were updated in two commits ahead of the pointer commit, so the root module suite is green at the pointer commit"
patterns-established:
- "Screens whose records carry privilege (a group code) guard create, re-code and delete in the Form before-hooks with cabana.Allows and answer cabana.ForbiddenError with details on the field"
- "A test of a privileged code that must not exist yet uses a config override with a code unique to the run: the harness database is shared and holds several groups coded admin"
requirements-completed: [SC-1, SC-2, SC-4] # copied from the plan; phase success criteria, which plan 05 finishes with full unit tests, the gate and the security review
coverage:
- id: D1
description: "Groups field on the user form: writes exactly the chosen memberships on create and update, leaves them alone when the field is absent, refuses unknown ids, and never reaches a user API payload"
requirement: SC-2
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_privileged_test.go#TestAdminUserGroupsField"
status: pass
- kind: integration
ref: "go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1"
status: pass
- kind: integration
ref: "go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$' -count=1"
status: pass
human_judgment: false
- id: D2
description: "T-12-18 guard: adding or removing a privileged group needs golem15.users.manage_privileged_groups on create and update; refusals are 403 with details on groups and write nothing; options and labels are flagged locked; the list is config read per request; the takeover guard follows the membership (D-30)"
requirement: SC-4
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_privileged_test.go#TestAdminPrivilegedGroups"
status: pass
human_judgment: true
rationale: "The test fails when the lock is switched off (checked once by hand), but the systematic guard-removal harness and the security review are plan 05. A security guard on a plugin shared across projects should be read by a person before it is published."
- id: D3
description: "User Groups screen: permission gating, list with users_count from one query, code format and uniqueness, checkbox permission editor stored as a JSON object of code to 1, delete with pivot cleanup"
requirement: SC-1
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_groups_test.go#TestAdminGroups"
status: pass
human_judgment: false
- id: D4
description: "D-06: creating a group with a privileged code, changing a code to or from a privileged one and deleting a privileged group need the extra permission; a name-only edit of a privileged group does not"
requirement: SC-4
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_privileged_test.go#TestAdminPrivilegedGroups"
status: pass
human_judgment: true
rationale: "Same as D2: covered by a passing test that fails without the guard, awaiting plan 05's review."
- id: D5
description: "Organisations screen: permission gating, list, slug derived from the name, slug format and uniqueness, avatar attached under the organisation's morph name, delete that releases the members"
requirement: SC-1
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_organisations_test.go#TestAdminOrganisations"
status: pass
- kind: unit
ref: "../fonoteka.go/plugins/golem15/user/admin_organisations_test.go#TestSlugify"
status: pass
human_judgment: false
- id: D6
description: "Organisation members relation manager: link sets and unlink clears users.organisation_id, lists are scoped to the organisation, users_groups and every other user column stay unchanged"
requirement: SC-2
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_organisations_test.go#TestAdminOrganisationMembers"
status: pass
human_judgment: true
rationale: "A member of another organisation cannot be linked (the plan expected a move), and organisation_role is not cleared on unlink. Both need a decision; see Open items."
- id: D7
description: "Navigation: Users, User Groups and Organisations are side items of `user`, in that order, each shown only with its permission"
requirement: SC-1
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_organisations_test.go#TestAdminOrganisations"
status: pass
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/admin_groups_test.go#TestAdminGroups"
status: pass
human_judgment: false
- id: D8
description: "The application carries the plugin: allow-list entry for golem15_user_frontend_permissions, submodule pointer at the plugin head, fixed test lists updated, every workspace module green on a framework that contains v0.1.3"
requirement: SC-4
verification:
- kind: integration
ref: "go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 -v"
status: pass
- kind: integration
ref: "go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1"
status: pass
- kind: integration
ref: "go test ./... -count=1 in each of plugins/golem15/{user,fonoteka,golem,feedback}"
status: pass
- kind: other
ref: "git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user = git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD = 4693155"
status: pass
human_judgment: false
- id: D9
description: "The three screens as an administrator sees them in the admin SPA (locked chips in the groups field, the forbidden banner, the group permission checkboxes, the members tab), and the README wording"
verification: []
human_judgment: true
rationale: "No browser was opened: the screens are YAML-driven and were exercised through the admin API only."
duration: 32min
completed: 2026-10-05
status: complete
---
# Phase 12.1 Plan 04: Groups field, User Groups and Organisations Summary
**An administrator now sets a user's groups from the user form, manages user groups with their frontend permissions, and manages organisations with their members. Making or unmaking a member of a privileged group (default code `admin`), and creating, re-coding or deleting such a group, needs `golem15.users.manage_privileged_groups` on the server. The application repository records the plugin head in a local commit and every workspace module passes. Nothing was pushed.**
## Performance
- **Duration:** 32 min
- **Started:** 2026-10-05T11:36:42Z
- **Completed:** 2026-10-05T12:09:00Z
- **Tasks:** 4 of 4, plus the application test-list catch-up the orchestrator assigned
- **Files modified:** 29 in sm-user-plugin, 6 test files in fonoteka.go besides the submodule pointer, 2 planning files in summercms.go besides this summary
## Accomplishments
- The user form has a Groups field. It is the only place a membership changes; a save that does not send the field leaves the memberships alone.
- For an administrator without `golem15.users.manage_privileged_groups` the privileged groups are locked in that field. A save that adds or removes one answers 403 with `users.privileged_group_forbidden`, marks `groups`, and writes nothing: no column of the user and no pivot row. This holds on create too.
- User Groups is a screen behind `golem15.users.access_groups`: name, code, creation date and member count in the list; name, code, description and a checkbox permission editor in the form; a delete that takes the group's memberships with it and leaves the members.
- Organisations is a screen behind `golem15.users.access_users`: name, slug (filled from the name when empty), description, avatar, and a Members tab that adds and removes users.
- The application's six stale test lists are up to date, the schema allow-list names the new table, and the submodule pointer records the plugin head.
## Commits
### sm-user-plugin (branch master, local)
| Task | Commit | Subject |
|------|--------|---------|
| 1 | `9611708` | feat: manage a user's groups from the user form, privileged groups locked |
| 2 | `a399287` | feat: User Groups admin screen with the users count and privileged codes protected |
| 3 | `58595fe` | feat: Organisations admin screen with the avatar and the members manager |
| 3 | `4693155` | docs: describe the User Groups and Organisations screens and the privileged-group rules |
Plugin head before `5805c63`, after `469315510809f703fc315aceaa9ef902794a0237`. The tree is clean and 14 commits ahead of its origin. The field, the pivot model and the lock provider are all in `9611708`; its parent has no `AdminRelationLocks`.
### fonoteka.go (branch master, local)
| Purpose | Commit | Subject |
|---------|--------|---------|
| Test lists (framework v0.1.3) | `ca746fc` | test(12.1-04): list the Phase 12.1 cabana routes and row-state messages in the admin inventories |
| Test lists (user plugin) | `b35442a` | test(12.1-04): expect the user plugin's admin navigation, models and migrations |
| Task 4 | `35a727f` | build: bump sm-user-plugin (admin screens for users, groups and organisations) |
Application head before `d1abcab`, after `35a727f1ddfde0a443cf203193bf3705fe3158a1`, 3 commits ahead of its origin. `35a727f` changes exactly `parity/schema_diff_test.go` and `plugins/golem15/user`; `go.mod` is unchanged.
### summercms.go
Framework head `fcc86ac45e1011c9bcb243cac965c8af44311ea1`; the tag `v0.1.3` (`df5cace`) is its ancestor. No framework file changed. Planning docs only.
**Tracer gate (Task 1).** Interactive, end-of-phase, automated-only: the verify chain was re-run at the commit and passed, so execution continued without a checkpoint. It could pass only because the application's stale lists were fixed first (see below).
## Publication state and pending steps
| Item | Value |
|------|-------|
| Plugin remote head (`git ls-remote origin refs/heads/master`) at the start of Task 4 | `0fe5b91b632a0ab784f3cecd2d5cf168528062ac` |
| The same at the end of Task 4 | `0fe5b91b632a0ab784f3cecd2d5cf168528062ac` |
| Framework tag on origin (`git ls-remote --tags origin v0.1.3`) | not listed |
| Pushed by this plan | nothing, in any repository |
The pointer in fonoteka.go names a plugin commit that is not on the plugin's origin. That is safe only while fonoteka.go itself is unpushed. Pending, in this order:
1. Push framework `master` and `v0.1.3` (pending since plan 02, the user's step).
2. **Push sm-user-plugin**: plan 05 Task 3, after the security review, and only when `git ls-remote --tags origin v0.1.3` lists the tag.
3. **Push fonoteka.go**: the user, after step 2.
## Application test lists (orchestrator scope)
Each failure was confirmed to be a stale list before the list changed.
| Test | Cause confirmed | Change |
|------|-----------------|--------|
| `TestPhase09SecurityRoutes` | The two unexpected routes are the plan 01 bulk action and record action routes; both are raw and carry the `backend` guard, which the test asserts for every listed route | two entries in `phase09AdminRoutes` |
| `TestPhase10ControllerCopy` | The list messages gained the three row-state keys with resolved Polish defaults | three keys in `phase10ListMessageKeys`. The form half reads named keys only and needed no change |
| `TestAdminMetadataFiltering` | The developer role sees `[fonoteka user]` because the four Winter user codes default to that role. The same test's publisher half still passes with an empty list, so the `user` item is shown only to a principal who holds a permission for it | expectation `[fonoteka user]` |
| `TestHiddenNeverMarshals` | The count was 5; the user plugin registers 7 models: `FrontendPermission` (plan 03) and the `UsersGroup` pivot (this plan). The marshalling checks pass for all of them | `expectedUserModels = 7` |
| `TestMigrateSeedsCanonicalGenres`, `TestRollbackLastIsolatesFonoteka` | The user plugin's history has the seven earlier ids plus the three additive ones of plan 03 | count 7 becomes 10 (three places) |
None was a real defect. `deferred-items.md` has both entries resolved and WINDOWS entry 10 is marked fixed.
The two list commits precede the pointer commit, so at `ca746fc` and `b35442a` the recorded pointer is still the old plugin head. The workspace builds from the plugin's working tree, so this affects only someone who checks out those two commits with the submodule at the recorded pointer. The order was chosen so the root module suite is green at the pointer commit, which Task 4 requires.
## Test results per module (at plugin `4693155`, application `35a727f`, framework `fcc86ac`)
| Module | vet | test | Time |
|--------|-----|------|------|
| fonoteka.go root (`fonoteka`, `parity`) | pass | pass | 86 s |
| `plugins/golem15/user` (3 packages) | pass | pass | 28 s |
| `plugins/golem15/fonoteka` (10 packages) | pass | pass | 121 s |
| `plugins/golem15/golem` (5 packages) | pass | pass | 9 s |
| `plugins/golem15/feedback` (2 packages) | pass | pass | 12 s |
| summercms.go (36 packages with tests) | pass | pass, no `FAIL` line | 60 s |
Other measured times, for VALIDATION.md:
| Command | Time |
|---------|------|
| `go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot\|TestUserAPINuxtFlows\|TestParityCorpus)$' -count=1 -v` | 54 s, 3 of 3 pass |
| `go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminPrivilegedGroups\|TestAdminUserGroupsField)$' -count=1` | 6 to 7 s |
| `go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminGroups\|TestAdminPrivilegedGroups)$' -count=1` | 7 s |
| `go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminOrganisations\|TestAdminOrganisationMembers)$' -count=1` | 6 s |
| `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin\|TestPhase09\|TestPhase10\|TestPhase12Threats)' -count=1` | 14 s |
| Task 1 verify chain end to end | 61 s |
## Contract names (inputs to plan 05)
| Name | Shape |
|------|-------|
| `usersAdminController.AdminRelationLocks` | `(ctx, field string) (cabana.RelationLock, error)`; for `groups` the privileged group ids and the message `golem15.user::lang.users.privileged_group_forbidden`, empty for an administrator who passes `cabana.Allows` for the extra permission and for every other field |
| `models.UsersGroup` | `UserID` (`user_id`), `UserGroupID` (`user_group_id`), both primary key; table `users_groups` |
| `controllers.PermissionAccessGroups` | `"golem15.users.access_groups"` |
| `usergroupsAdminController` | ID `golem15.user.usergroups`; implements `AdminPermissioned`, `AdminRecordSource`, `ListExtendQuery`, `FormBeforeCreate`, `FormBeforeUpdate`, `FormBeforeDelete`, `FormAfterDelete`, `cabana.PermissionEditorProvider` |
| `organisationsAdminController` | ID `golem15.user.organisations`; implements `AdminPermissioned`, `AdminRecordSource`, `FormBeforeCreate`, `FormBeforeUpdate`, `FormBeforeDelete`, `RelationExtendManageQuery`, `cabana.AdminRelationContractProvider` (relation `members`, `cabana.RelationHasMany`, foreign key `organisation_id`, columns `name` and `email`) |
| `models.UserGroup` | new `Fillable()` (name, code, description), `Rules()` (`name: required\|between:3,64`, `code: required\|unique:user_groups`), read-only `UsersCount int64` (`users_count`, json "-"); `Permissions` is still `*string` |
| `models.Organisation` | new `Fillable()`, `Rules()`, `MorphName()`, `AttachRelations()`, `BeforeValidate(*gorm.DB)` (pointer receiver), relation field `Members []User` (json "-"); constant `models.OrganisationAvatarField` |
| `models.Slugify` | `(text string) string` |
| `models.PermissionSet.Text` | `() *string`; nil for a set without a non-zero value |
| `models.ParsePermissionSet` | unchanged from plan 03: `(raw []byte) (PermissionSet, error)` |
| Phrase keys added (en and pl) | `user.groups`, `user.empty_groups`, `users.privileged_group_forbidden`; `group.{id,name,code,code_comment,code_invalid,description_field,created_at,users_count}`; `groups.{all_groups,menu_label,list_title,new_group,list_empty,delete_confirm,privileged_forbidden}`; `organisation.{label_plural,menu_label,id,name,slug,slug_comment,slug_invalid,description,avatar,members,new,list_empty,add_member,remove_members,remove_members_confirm,members_empty}` |
Harness additions (package `user` tests): `adminGroupsPath`, `adminOrganisationsPath`, `permAccessGroups`, `groupPath(id)`, `organisationPath(id, suffix)`, `sortedIDs`, and on `adminEnv`: `groupOptions`, `recordGroups`, `group`, `sideMenu`, `organisation`, `seedOrganisation`.
## Decisions Made
- **`Organisation.Members`.** The plan said no field is added to the model. The framework's relation manager requires the relation to be a field of the owner model, so `Members []User` was added. It is a Go relation, not a column: `updates/10_organisations.go` is unchanged and `TestSchemaMatchesPHPSnapshot` passes.
- **Format refusals.** `group.code_invalid` and `organisation.slug_invalid` are two phrase keys beyond the plan's list, with Laravel's alpha_dash wording. Without them the 422 would be English only.
- **Organisation delete confirm.** The framework default. The UI-SPEC is a signed copy contract and names none for organisations.
- **List title of Organisations.** `organisation.label_plural`, the PHP key and value.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] The members relation needs a field on the owner model**
- **Found during:** Task 3
- **Issue:** boot failed with "relation members is not on the owner model".
- **Fix:** `Organisation.Members []User` with `foreignKey:OrganisationID` and json "-".
- **Files modified:** models/organisation.go
- **Verification:** `TestAdminOrganisationMembers`, `TestHiddenNeverMarshals`, `TestSchemaMatchesPHPSnapshot`
- **Committed in:** 58595fe
**2. [Rule 3 - Blocking] Existing assertions pinned the old form and navigation**
- **Found during:** Tasks 1 and 3
- **Issue:** `TestAdminUserFormFields` pinned the user form's field list without `groups`; `TestAdminUsersTracer` pinned one side item for the users permission, which now also opens Organisations.
- **Fix:** both expectations updated.
- **Files modified:** admin_users_test.go
- **Committed in:** 9611708, 58595fe
**3. [Rule 2 - Missing critical] Localized refusal of a malformed code or slug**
- See Decisions Made. **Committed in:** a399287, 58595fe
### Other departures from the plan text
- **"A user of another organisation moves when linked here."** This does not happen. The framework's hasMany relation manager offers, and accepts, only related rows whose foreign key is NULL; linking a member of another organisation answers 422 (`ids: contains an ineligible target`) and changes nothing. The test asserts that, and that the user can be linked after being removed from the other organisation. The user form's organisation field still moves a user directly.
- **`controllers/app_access.go`** is a new file outside `files_modified`: one small accessor type for the two new controllers. `usersAdminController` keeps its own accessors, so the file with the guards of plan 03 was changed only where the plan says.
- **The D-06 matrix runs on codes of its own.** The harness database holds several groups coded `admin` from other tests, and validation (unique code) runs before the hooks, so "create a group with code admin" would answer 422 there. The test overrides the privileged list to `[admin, priv-<stamp>, priv2-<stamp>]`; the `admin` group itself is covered for re-code and delete.
- **Commits.** Tasks commit code and tests together so each commit is green; Task 3 has a separate docs commit for the README.
- **Acceptance check with base `8a65890`.** As in plan 03, the real base is one commit later; the diff of `updates/10_organisations.go` is empty against both.
---
**Total deviations:** 3 auto-fixed (2 blocking, 1 missing critical) and the departures above.
**Impact on plan:** No scope added beyond the orchestrator's test-list catch-up. No framework file, migration, config file, `classes/privileged.go` or Go module changed (`git -C <plugin> diff 5805c63..HEAD -- go.mod go.sum updates config classes` is empty).
## Issues Encountered
None that blocked. The first run of Task 1's verify chain was red on the three known application lists; they were fixed (orchestrator scope) before the Task 1 commit was accepted through the tracer gate.
## Not verified here
- No browser: locked chips, the forbidden banner, the checkbox editor and the members tab were exercised through the admin API only.
- The guards were each switched off once by hand to see `TestAdminPrivilegedGroups` fail (the relation lock: fails at the options check; the group-code permission: fails at the first create). That is not the systematic removal harness of plan 05.
- Concurrent saves of the same user's groups by two administrators.
- The avatar's blob removal when an organisation is deleted: the test checks the attachment row is written on save, not what the delete does with it.
- `scripts/check-phase*.sh` gates were not run.
## Open items for plan 05's review
1. **`organisation_role` outlives the membership.** Unlinking a member clears `organisation_id` only (the framework writes just the foreign key; PHP's relation controller does the same). The user keeps, for example, `organisation_role = owner`, and carries it into the next organisation they are added to. The application's `CanManageOrg` reads that role. Deleting an organisation does clear the role. Decide whether the plugin should clear the role on unlink, which needs a framework unlink hook or a plugin-side route.
2. **Deleting an organisation cascades into other plugins.** The application's organisation credential tables reference `golem15_user_organisations` with `ON DELETE CASCADE`, so `golem15.users.access_users` can remove an organisation's credentials by deleting the organisation. PHP's screen has the same delete. No confirm text says so.
3. **A group's boolean permission values are dropped by an edit.** `ParsePermissionSet` skips non-numeric values, so a legacy row such as `{"x":true}` shows unchecked in the editor and is saved without `x`; `classes.UserPermissionGrants` (API token principals) counted it as granted. This extends item 4 of plan 03.
4. **Validation answers before the guard.** A create or re-code that would be refused with 403 answers 422 instead when the body also fails validation (for example a duplicate privileged code). Nothing is saved either way; the 422 confirms that the code exists, which the list shows anyway.
5. **The seeded groups can be deleted.** `guest` and `registered` are ordinary groups, so `golem15.users.access_groups` may delete or re-code them. PHP has no delete button on groups; D-29 added one. No Go code outside the model reads the two codes today.
6. **Relation locks and the relation manager** (plan 02, property 4) stay unreachable for `users_groups`: no groups relation manager exists, and the organisation manager writes `users.organisation_id` only, which `TestAdminOrganisationMembers` asserts.
7. Open items 2 to 9 of 12.1-03-SUMMARY.md are unchanged.
## Known Stubs
None. `golem15.users.access_settings` and `impersonate_user` remain registered without a screen by decision (D-02).
## Threat Flags
None beyond the plan's threat model. The plugin still adds no route of its own; the organisation members link and unlink routes are the framework's, covered by T-12.1-32.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Plan 12.1-05 can add the full unit tests, the guard-removal harness, the gate script (it should run every workspace module, not only the root) and the security review, then publish the plugin.
- The demo server on 127.0.0.1:8431 was left alone.
## Self-Check: PASSED
- Created files exist: all 17 listed under key-files.created.
- Commits exist: plugin 9611708, a399287, 58595fe, 4693155 (`rev-list --count 5805c63..HEAD` is 4); application ca746fc, b35442a, 35a727f (`rev-list --count d1abcab..HEAD` is 3).
- Key links: `AdminRelationLocks` in `users_admin_controller.go` (3 matches); `IsPrivilegedCode` in `usergroups_admin_controller.go` (3 matches); `golem15.user.organisations` once in `admin_navigation.go` and once in the controller; `"golem15_user_frontend_permissions"` once in `schema_diff_test.go`.
- Acceptance criteria of all four tasks re-run and passing, including the same-commit check for the field, the pivot model and the guard, the unchanged `Permissions *string`, and the unchanged plugin remote head.
- Plan verification: plugin vet and tests green at each plugin commit's tree; application vet and full suite green at `35a727f`; framework vet and suite green; pointer equals the plugin head; nothing pushed.
---
*Phase: 12.1-user-plugin-admin-screens*
*Completed: 2026-10-05*