60 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.1-user-plugin-admin-screens | 05 | execute | 5 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.
This plan's slice: success criterion 5. After it, the phase is closed by evidence: one command proves the framework features, the three screens and the T-12-18 guard, and fails closed when any of them regresses.
Bring the code of plans 01 to 04 to full unit test coverage in both repositories (framework Go, admin SPA, the user plugin), add the phase gate `scripts/check-phase12.1.sh` on the pattern of `scripts/check-phase12.sh` and `scripts/check-phase12.2.sh`, write the security review and sign off the validation file.Purpose: per the project rule, unit tests are the last plan of a phase; plans 01 to 04 carried tracer and smoke tests only. Output: test files in both repositories, the gate script, 12.1-SECURITY-REVIEW.md and the validated 12.1-VALIDATION.md.
Repos: summercms.go (framework tests, SPA tests, gate script; planning docs in a separate commit), sm-user-plugin (plugin tests, committed in the plugin checkout with git -C ../fonoteka.go/plugins/golem15/user, then the pointer bumped in fonoteka.go as its own local commit). The plugin is pushed once, in the last step of Task 3, after the security review and only when the framework tag v0.1.3 is on origin; otherwise the push stays pending. fonoteka.go is never pushed by this plan. Production code changes only where a test exposes a real defect; each such fix is its own commit naming the threat or decision, with its README, docs, OpenAPI and dist duties when it is framework code. A framework fix made here is after the v0.1.3 tag: record it in the summary as needing a follow-up tag and do not move v0.1.3. Never add co-author tags. Framework tests and the gate's framework-facing output use neutral names.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- Gate:
scripts/check-phase12.1.shwith--self-test,--go,--security,--removal,--coverage,--spa,--openapi,--dist,--docs,--hygiene,--app,--evidence,--all; environment variablePHASE121_APP(default../fonoteka.go). - Framework tests:
TestPhase121Threats(cabana), theTestBulkAction*,TestListSchemaBulkActions*,TestRecordAction*,TestRowState*,TestForbidden*,TestPreview*,TestPasswordField*,TestVirtualFields*,TestFormRules*,TestPreset*,TestPermissionEditor*,TestRelationLock*,TestWritableForeignKey*,TestInvisibleColumn*,TestFilterOptions*families in themodules/cabana/phase121_*_test.gofiles; pact contract tests inmodules/pact/capabilities_test.go. - SPA tests: the vitest files listed in the frontmatter, including the five backstop cases.
- Plugin tests:
TestPhase121Threats(plugin),TestAdminUsers*,TestAdminGroups*,TestAdminOrganisations*,TestAdminRegistration, class and model unit tests. - Docs:
12.1-SECURITY-REVIEW.md, the validated12.1-VALIDATION.md.
Planner decisions recorded for this plan
- Security review author. CONTEXT asks for the security-review agent. When the executor's runtime can spawn it, its findings are recorded in 12.1-SECURITY-REVIEW.md; otherwise the review is self-performed by the executor against code and tests and the reviewer line says so (the 08-10 and Phase 12 precedent). The orchestrator may additionally run
/gsd-secure-phase 12.1after execution. - Removal stage scope. Every high or critical threat with disposition mitigate gets a removal row; medium and low threats are covered by named tests only. The removal stage edits tracked source while it runs and is therefore not part of
--all. The two D-30 threats (T-12.1-38 critical, T-12.1-39 high) each have a row. - Publication (plan-check revision). Of the two routes offered at the plan check, the plugin push moved here: it is the last step of Task 3, after the security review, and it runs only when
git ls-remote --tags origin v0.1.3lists the framework tag. Plan 04 Task 4 and Task 2 of this plan therefore bump the application's pointer in local commits and push nothing. When a condition does not hold the push is skipped and recorded as pending; it is never forced. - Coverage floor. 80 percent per listed package, as in Phase 12.
- Manual checks. The visual checks of the preview screen, row-state styling and the permission editor are one end-of-phase human-check item on Task 3 (
workflow.human_verify_modedefault). - Spec-less probe fallback: skipped (no requirement IDs, no SPEC.md); no probe predicates were generated.
(1) modules/cabana/phase121_threats_test.go: TestPhase121Threats with one subtest per mitigated framework threat, named by its id (T-12.1-01 to T-12.1-16), each asserting the protection through the admin API on the acme.roster fixture: 01 an id outside the list scope never reaches Run and a mixed selection is 409 with nothing changed; 02 an undeclared and an unregistered action answer 404 and a declared one without its permission answers 403 and is absent from the schema; 03 both action routes refuse a cookie request without the Ajax header; 04 a record action on an out-of-scope record is 404 and on a non-applicable record 409; 05 a bulk action failing on the last row leaves the first rows unchanged; 06 a plain hook error answers the generic 500 body with no error text while a ForbiddenError answers its message; 07 an unknown row state is not sent; 08 one log line per action run with the admin id and no record contents (captured with a slog handler); 09 a virtual field value is never stored or returned; 10 a password never appears in any response; 11 a protected foreign key is read-only without the opt-in; 12 a locked relation id cannot be added or removed on create or update; 13 an unknown permission code, an out-of-range value and a changed locked code are refused; 14 a preview-only field cannot be written; 15 a status partial that carries markup is served as nodes with only allowlisted attributes; 16 is covered in the SPA (winterUrl) and referenced by a comment naming the vitest case.
(2) ../fonoteka.go/plugins/golem15/user/phase121_security_test.go: TestPhase121Threats with one subtest per mitigated plugin threat (T-12.1-18 to T-12.1-25, T-12.1-27 to T-12.1-31, T-12.1-34, T-12.1-38 and T-12.1-39), using the plan 03 harness: 18 every Users, User Groups and Organisations route, bulk action and record action answers 403 without its permission; 19 no admin response carries a password, hash or reset or activation code; 20 a body carrying is_activated, permissions as a scalar, organisation_id or password as plain columns changes none of them; 21 an admin password reset stamps tokens_valid_after and an older token is refused; 22 exactly one invitation per created user and none on update; 23 a permanent delete leaves no user_throttle, users_groups or system_files row and is refused without the permission; 24 no user API payload carries last_seen, timestamps, permissions other than null or a non-empty groups list; 25 a failing last_seen write leaves login and refresh at 200; 27 the resolver's override, exactly-1 and wildcard rules; 28 (T-12-18 revisited, cited by that id in the subtest's comment) the full add, remove and create matrix with and without golem15.users.manage_privileged_groups, including a crafted body that changes a privileged membership together with other fields, and the answer of classes.HasGroupCode before and after; 29 the four privileged-code cases on the group form; 30 users_groups is byte-identical before and after every bulk action, every record action, every organisation members link and unlink, and no relation route for groups exists on the users or groups controller; 31 an overridden privileged list and a group with a NULL code; 34 the application's rule that groups is never serialized, asserted on the plugin's own payload builder; 38 (D-30, takeover of a privileged-group member) with the membership given through the user form's groups field by an admin who holds the permission: an admin holding only golem15.users.access_users who changes that member's email, submits a password for that member, or does both in one body that also changes the name, gets 403 each time with details on the refused fields, and afterwards the email, the name, the password hash and tokens_valid_after are byte-identical to before, a login with the old password succeeds and a login with the attempted password fails; a name-only update by that admin answers 200; the same email change and password reset by an admin who also holds golem15.users.manage_privileged_groups answer 200; after the membership is removed the admin without the permission may change the email; 39 (D-30, permanent delete) the form delete of a privileged-group member and a bulk delete that contains that member together with ordinary users each answer 403 for the admin without the permission, and every selected user and its users_groups, user_throttle and system_files rows remain; both succeed with the permission. The assertions of 38 and 39 are written so that each fails when its check is removed (the removal stage of Task 3 removes them one at a time).
(3) scripts/check-phase12.1.sh, first stages (the remaining stages are added in Task 3): the header contract and set -euo pipefail of check-phase12.2.sh; ROOT and APP="${PHASE121_APP:-$ROOT/../fonoteka.go}"; the go test -json detector of check-phase12.sh (exit codes for fail or build failure, skip, zero tests or "no tests to run", non-JSON output, a required name that did not pass); --security running the framework's named tests by prefix (the TestPhase121Threats, TestBulkAction, TestRecordAction, TestRowState, TestForbidden, TestSoftDeletedRecord, TestPreview, TestPasswordField, TestVirtualFields, TestFormRules, TestPermissionEditor, TestRelationLock, TestWritableForeignKey, TestInvisibleColumn and TestFilterOptionsController families in ./modules/cabana) and the plugin's (TestPhase121Threats, TestAdminPrivilegedGroups, TestAdminPrivilegedMember, TestAdminUserGroupsField, TestAdminUserActions, TestAdminUserForceDelete, TestAdminUserPassword, TestAdminUserInvite, TestAdminAvatarSharedWithAPI, TestAdminGroups, TestAdminOrganisations, TestAdminOrganisationMembers, TestLastSeen in ./plugins/golem15/user, and TestMergedPermissions, TestPermissionSetScan in its classes package) inside the application workspace, each prefix needing at least one passing top-level test and any skip refusing; --self-test proving the detector fails closed on planted inputs (a failing test, a skipped test, a run with no tests, non-JSON output, a missing required prefix, a build failure); a usage text listing every stage name of the Artifacts section. Make the script executable.
(4) If a subtest exposes a real defect, fix it in the owning repository in its own commit naming the threat id, then re-run.
scripts/check-phase12.1.sh --self-test && scripts/check-phase12.1.sh --security && go test ./modules/cabana -run '^TestPhase121Threats$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestPhase121Threats$' -count=1 -v
<fails_when>Any command exits non-zero; the gate prints a line starting with "refuse:"; either named run lacks "--- PASS: TestPhase121Threats", prints "--- SKIP", or prints "no tests to run".</fails_when>
<acceptance_criteria>
- test -x scripts/check-phase12.1.sh succeeds and scripts/check-phase12.1.sh --self-test exits 0.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestPhase121Threats$' -count=1 -v prints a "--- PASS" line for each of the subtests T-12.1-28, T-12.1-38 and T-12.1-39 and no "--- SKIP" line.
- go test ./modules/cabana -run '^TestPhase121Threats$' -count=1 -v prints a "--- PASS" line for each of the subtests T-12.1-01 to T-12.1-15.
- grep -c 'T-12-18' ../fonoteka.go/plugins/golem15/user/phase121_security_test.go prints at least 1 (the revisited threat is cited by its original id).
- Running scripts/check-phase12.1.sh --security with one named plugin test temporarily renamed makes it exit non-zero with a "refuse: missing named test" line (checked once by hand and recorded in the summary; the rename is reverted).
</acceptance_criteria>
One command proves, fail-closed, that the privileged-group guard and every other mitigated threat of the phase hold in both repositories.
(1) Framework (summercms.go, neutral acme names). modules/pact/capabilities_test.go: the new types compile against a sample controller and the RowState constants hold their three values. phase121_bulk_test.go, mirroring bulk_test.go: TestBulkActionEmpty, TestBulkActionDuplicates, TestBulkActionOrder, TestBulkActionAbsent, TestBulkActionPartial, TestBulkActionScope, TestBulkActionRollback, TestBulkActionConcurrent, TestBulkActionPermissions, TestBulkActionUndeclared, TestBulkActionCSRF, TestBulkActionMessageLocalized, TestBulkActionBodyCap, and TestListSchemaBulkActionsFiltered (per-principal list, the cached schema unchanged after a filtered request). phase121_record_test.go: TestRecordActionScope, TestRecordActionApplies, TestRecordActionBody, TestRecordActionPermissions, TestRecordActionOffered, TestRecordActionRollback, TestRecordActionAppliesError. phase121_rowstate_test.go: TestRowStateOrder, TestRowStateUnknownDropped, TestRowStateLengthMismatch, TestRowStateHookError, TestRowStateAbsentKey, TestRowStateOncePerPage. phase121_forbidden_test.go: TestForbiddenFromEveryHook (before and after create, update and delete, bulk delete, relation link and child hooks), TestForbiddenLocalized, TestForbiddenRollsBack, TestForbiddenEmptyMessage. phase121_preview_test.go: TestPreviewSchema, TestPreviewFieldNeverWritten, TestPreviewHeaderPartialScoped, TestPreviewMessagesDefaults. phase121_fields_test.go: TestPasswordFieldNeverProjected, TestVirtualFieldsContext, TestVirtualFieldsNested, TestFormRulesReplaceModelRules, TestFormRulesRequiredMerge, TestPresetSchemaShapes. phase121_permission_test.go: TestPermissionEditorModes, TestPermissionEditorUnknownCode, TestPermissionEditorLocked, TestPermissionEditorKeepsUnoffered, TestPermissionEditorOptionsPerRequest, TestPermissionEditorProviderError. phase121_relation_lock_test.go: TestRelationLockCreate, TestRelationLockUpdate, TestRelationLockBelongsTo, TestRelationLockAbsentField, TestRelationLockNoProvider, TestWritableForeignKeyOptIn, TestWritableForeignKeyScope. phase121_list_test.go: TestInvisibleColumnSearchAndRows, TestFilterOptionsControllerFirst, TestFilterOptionsModelFallback. phase121_schema_boot_test.go: TestPhase121BootErrors, a table of every boot error message plans 01 and 02 introduced (bulkActions without showCheckboxes, unregistered or unlabelled bulk and record actions, a scalar action list, a reserved or duplicate action name, recordActions without preview, a null preview, a password field outside FormVirtualFields, a virtual field of a wrong type, an unsupported preset type, preset on a non-text field, permissioneditor without mode or without the provider, mode on another type, WritableForeignKey on belongsToMany), each asserting the plugin id, controller id and file in the message.
(2) Plugin (sm-user-plugin). classes/admin_actions_test.go: every function of classes/admin_actions.go for its changed-row count, idempotency, the ban semantics across several throttle rows and a NULL-IP row, the suspension window as a pure read, and the cleanup of ForceDeleteCleanup. classes/privileged_test.go: default list, overridden list, blank entries, case sensitivity, a NULL code, PrivilegedGroupIDs, and TestIsPrivilegedMember (a user in no group, in an ordinary group only, in a group without a code, in a listed group, and under an overridden list; D-30). classes/permissions_test.go: extend the table (several groups, numeric strings in stored JSON, an empty group set, a code present only at user level). classes/last_seen_test.go: the five-minute boundary on both sides and a deactivated user. models/permission_set_test.go, models/slug_test.go (the table of inputs the SPA preset test uses: accents, punctuation runs, leading and trailing separators, an empty string, a long text), models/admin_models_test.go (Fillable and Rules of UserGroup and Organisation, MorphName, AttachRelations names, BeforeValidate, FilterScopes, JSON marshalling of User without the new fields). admin_registration_test.go TestAdminRegistration: the five permission codes with their tab, the navigation tree with three side items and their permissions, every embedded admin file is readable from AdminFS, every lang key the YAML and the controllers name resolves in en and pl. admin_users_edge_test.go, admin_groups_edge_test.go, admin_organisations_edge_test.go: the remaining branches of the three controllers (unknown partial name, an unknown filter scope, options for another field, a group update that keeps a privileged code, an organisation delete without members, a members link of a deactivated user, search and sort on each list, and the remaining branches of the D-30 guard: a superuser passes, and a failed membership query is the opaque 500 with nothing changed). updates/admin_columns_test.go: each migration up, down and up again, and that the frontend permissions code is unique.
(3) Measure coverage with go test -coverprofile over modules/pact and modules/cabana and over the plugin's packages with -coverpkg, as check-phase12.sh does; add tests until each package is at 80 percent or more; record the numbers in the summary.
(4) Commits: framework tests in summercms.go; plugin tests inside the plugin checkout; then bump the submodule pointer in fonoteka.go as its own local commit. Push nothing in this task: the plugin is pushed in the last step of Task 3, after the security review and only when the framework tag v0.1.3 is on origin, and fonoteka.go is not pushed by this plan. Read the plugin's remote head with git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master at the start and at the end of the task and record both shas in the summary.
go vet ./... && go test ./modules/pact/... ./modules/cabana/... -count=1 && go test ./modules/cabana -run '^(TestBulkAction|TestRecordAction|TestRowState|TestForbidden|TestPreview|TestPasswordField|TestVirtualFields|TestFormRules|TestPreset|TestPermissionEditor|TestRelationLock|TestWritableForeignKey|TestInvisibleColumn|TestFilterOptions|TestPhase121)' -count=1 -v && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 -v && go -C ../fonoteka.go test ./... -count=1 && test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --short)" && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"
<fails_when>Any command exits non-zero; a run prints a line starting with "FAIL", a "--- SKIP" line for a test of this phase, or "no tests to run"; the named cabana run lacks "--- PASS: TestPhase121BootErrors" or "--- PASS: TestBulkActionConcurrent"; the plugin checkout has uncommitted changes; the application's pointer differs from the plugin head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
<acceptance_criteria>
- go test ./modules/cabana -run '^TestPhase121BootErrors$' -count=1 -v prints "--- PASS: TestPhase121BootErrors".
- go test ./modules/cabana -run '^TestBulkAction' -count=1 -v prints "--- PASS" lines for at least the cases Empty, Duplicates, Order, Absent, Partial, Scope, Rollback, Concurrent, Permissions, Undeclared and CSRF.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminRegistration$' -count=1 -v prints "--- PASS: TestAdminRegistration".
- The coverage numbers recorded in the summary are at least 80 percent for modules/pact, modules/cabana and each of the plugin's packages root, classes, controllers, models and updates.
- go vet ./... && go test ./... -count=1 exits 0 in summercms.go and go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1 exits 0 at the task's commits.
- git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user equals git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD (the pointer is bumped in a local commit), and the sha printed by git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master at the end of the task equals the sha read at its start (both are in the summary): this task pushed nothing.
- go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^TestIsPrivilegedMember' -count=1 -v prints a "--- PASS" line and does not print "no tests to run" (D-30).
</acceptance_criteria>
Every Go behaviour the phase added, in the framework and in the user plugin, is covered by a unit or integration test, at or above the coverage floor.
(1) SPA unit tests in the existing vitest style (describe titles carry the decision id, selectors by data attributes, typed fixtures). BulkActionsMenu and ListToolbar and ListView (D-09, S1): no menu with zero permitted actions; a disabled trigger keeps its label; items in declared order; a long label wraps; the confirm uses the action's confirm text or the default with :action and :count; the dialog stays busy until the POST settles; the three failure rows of the S1 table; and the backstop "focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel". RowStateBadges and DataTable (D-12, S4): each state's badge and text classes, combined states, the fixed order, the data-row-states attribute, an unchanged row without states, and the backstop "a row state outside the fixed set renders no badge and no class". RecordActions (D-10, S2): rendering order, the confirm, the busy state, the done, stale and gone events, the 403 toast. PreviewView and PreviewField (D-11, S3): each row of the PreviewField table, the empty dash, tabs without preview fields hidden, the hint skeleton and keep-previous behaviour, the footer with zero, one and several actions, the load failure. winterUrl and router: the backstop "mapWinterUrl maps preview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route", plus rejection of a foreign controller and of a non-numeric id, and CONTROLLER_ROUTES containing preview. PermissionEditorField (D-16, S5): sections by tab, the Other section, the empty state, the invalid border, read-only rendering, the locked row, and the backstop "radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent". RelationField (D-07, S6): the locked chip, the note line, and the backstop "a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace". FormErrorBanner and FormView (S6 forbidden save): the banner with and without a server message, details on fields with focus on the first, values and dirty state kept, the banner cleared on the next save, a 403 on delete as a toast. PasswordField, formState and FormView (S7): empty on load, the toggle and its aria-pressed, an empty password left out on update and sent on create, both fields cleared after a save; preset on create only, stopping at the first manual edit, the slug rule on the same table of inputs the plugin's slug test uses. registry: the two new types and their set memberships. DataTable: invisible columns are not rendered.
(2) Complete scripts/check-phase12.1.sh with the remaining stages: --go (go vet and go test for the framework, each through the detector), --spa (typecheck and vitest, refusing "No test files found"), --openapi and --dist (the two existing check scripts), --docs (TestDocsTree and docs:build --check), --hygiene (no consuming-application name in the framework files this phase added or changed, listed in PHASE_FILES, nor in the module READMEs, docs and admin/src; the pattern is the APP_NAMES expression of check-phase12.2.sh), --app (go vet and the full go test of the application workspace through the detector, including the schema parity and the user API parity tests), --coverage (the 80 percent floor per listed package with its number printed), --removal (the anchor-exact mutation harness of check-phase12.sh with one row per high or critical mitigated threat: the lockScoped call in BulkAction, the shared own-permission check, requireAjax on the two routes, loadRecord and the Applies check in RecordAction, the virtual-field skip in BindWritableFields, the password exclusion from projection, the WritableForeignKey condition, the checkRelationLocks call, the option-code check of the permission editor, the Users controller's RequiredPermissions, json "-" on the new user fields, the users controller's AdminRelationLocks, the group code check in the before-hook, the privileged-member check at the top of the users controller's FormBeforeUpdate (D-30; the subtest T-12.1-38 of the plugin's TestPhase121Threats must then fail) and the privileged-member check in its FormBeforeDelete (D-30; the subtest T-12.1-39 must then fail); each row names the test that must then fail on an assertion; a dirty file is refused; every file is restored byte for byte and compared with cmp; not part of --all), --evidence (every threat id found in the threat_model blocks of the five plans appears in 12.1-SECURITY-REVIEW.md with a test name, and 12.1-VALIDATION.md has no pending or TBD row and says nyquist_compliant true), and --all (every stage except removal, one PASS or FAIL line per stage, stopping at the first failure). Extend --self-test so each new detector (coverage below the floor, a hygiene hit, an evidence gap, a removal row whose test still passes) is proven on planted input.
(3) Run scripts/check-phase12.1.sh --removal and then scripts/check-phase12.1.sh --all; fix what they expose (a framework fix after the tag is its own commit with its documentation duties and is recorded as needing a follow-up tag).
(4) Planning documents, in a separate commit. 12.1-SECURITY-REVIEW.md in the format of the Phase 12 review: frontmatter (phase, reviewed date, reviewer per the decision recorded above, threats_open, gate, removal_harness), a table of T-12.1-01 to T-12.1-40 and T-12.1-SC with category, component, severity, disposition, the production mitigation with file and function, the test or gate stage, the observed result and the residual risk; a section on T-12-18 (the eight write paths of RESEARCH and how each is guarded or accepted); a section on D-30 (T-12.1-38 and T-12.1-39: the three guarded operations on a privileged-group member, the plugin commit in which the guard landed, the removal-stage result for both checks, and the boundary: which operations on such a member still need only golem15.users.access_users and why); the accepted risks T-12.1-26 and T-12.1-32; a list of fixes made during the review. 12.1-VALIDATION.md: replace the seeded map rows with final rows keyed by real task ids (12.1-01-T1 to 12.1-05-T3), among them the D-30 row (T-12.1-38 and T-12.1-39, TestAdminPrivilegedMember and the two threat subtests), the exact commands the plans ran, file-exists ticks and statuses; tick the Wave 0 items; record the measured run times and the feedback latency; tick the sign-off list; set wave_0_complete true, nyquist_compliant true, status validated, the validated date and the gate command.
(5) Publication of the shared plugin (threat T-12.1-40), last, and only after steps (1) to (4) are committed and scripts/check-phase12.1.sh --all has passed on the final heads. First read the plugin's remote head with git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master and note it. If a fix made in this task moved the plugin head, bump the application's pointer in a local commit. Then check four conditions: (a) the gate passed; (b) 12.1-SECURITY-REVIEW.md says threats_open 0; (c) git ls-remote --tags origin v0.1.3, run in summercms.go, lists the framework tag; (d) no framework production code changed after the tag, which holds when git diff --name-only v0.1.3 HEAD -- modules cmd admin/src lists nothing but Go test files and files under a testdata directory. When all four hold, push master of the plugin checkout to its origin (plain git or the user's submodule tool ssu), never with force, and confirm that the plugin's remote head now equals its local head. When any condition does not hold, do not push: record the line "pending: push sm-user-plugin" in the summary and in STATE.md together with the condition that failed and what has to happen first (for (c): the push of framework master and v0.1.3 that plan 02 left pending; for (d): the follow-up framework tag on origin). A push refused for authentication or network reasons is reported as an authentication gate and recorded as pending in the same way. fonoteka.go is not pushed: record "push fonoteka.go" as the user's step once the plugin head is on its origin.
npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-phase12.1.sh --self-test && scripts/check-phase12.1.sh --removal && scripts/check-phase12.1.sh --all && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; the gate prints a line starting with "refuse:" or a "FAIL" stage line; a removal row reports that its named test still passed; the coverage stage prints a package below 80; the evidence stage reports a threat id without a test or a pending row; the application's pointer differs from the plugin head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
Start the application against the tagged framework, sign in as a backend admin holding golem15.users.access_users and golem15.users.access_groups but not golem15.users.manage_privileged_groups, and walk the three screens in light and dark mode: filter and search Users, open a banned and a deactivated user's preview, run Activate, Unban and a bulk Ban, create a user with an invitation, open the Permissions tab, try to add the admin group to a user, edit a group's permissions, add and remove an organisation member. Then, still as that admin, open a user who is in the admin group: change the name only and save; change the email and save; enter a new password and save; press Delete; and select that user together with another one in the list and use the bulk delete.
The screens match the UI-SPEC (row-state badges with text, one status callout on the preview, record actions before the single primary edit button, the segmented permission control, the locked admin group with its note, the forbidden banner when the locked group is forced through a crafted request), and nothing in the app's own user payloads changed. For the user in the admin group (D-30): the name-only save succeeds; the email save and the password save each show the forbidden banner with the marked field, keep what was typed and save nothing; Delete and the bulk delete each show a danger toast and delete nobody.
<why_human>Visual fit with the design system in both themes and the end-to-end feel of the screens cannot be asserted by unit tests.</why_human>
<acceptance_criteria>
- scripts/check-phase12.1.sh --all exits 0 and prints one PASS line for each of the stages go, security, coverage, spa, openapi, dist, docs, hygiene, app and evidence.
- scripts/check-phase12.1.sh --removal exits 0, and git status --short in summercms.go and in the plugin checkout prints no modified source file afterwards.
- npm --prefix admin test lists the five backstop cases as passed (their test titles contain the word backstop).
- grep -c 'nyquist_compliant: true' .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md prints 1 and grep -c '| TBD |' .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md prints 0.
- Every threat id of the five plans appears in the review: the sorted unique output of grep -ohE 'T-12\.1-(SC|[0-9]{2})' .planning/phases/12.1-user-plugin-admin-screens/12.1-0*-PLAN.md (41 ids: T-12.1-01 to T-12.1-40 and T-12.1-SC) equals that of the same grep over 12.1-SECURITY-REVIEW.md, and grep -c 'T-12-18' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md prints at least 1.
- grep -c 'threats_open: 0' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md prints 1.
- scripts/check-phase12.1.sh --removal prints one row for the privileged-member check in FormBeforeUpdate and one for the check in FormBeforeDelete, each reporting that the subtest T-12.1-38 or T-12.1-39 failed with the check removed and that the file was restored (D-30), and grep -c 'D-30' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md prints at least 1.
- Publication is recorded in the summary: for each of the conditions (a) to (d) of step (5) whether it held, and either the pushed plugin head sha (then, after git -C ../fonoteka.go/plugins/golem15/user fetch origin, git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD prints 0) or the line "pending: push sm-user-plugin" with the condition that failed (then the sha printed by git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master is the one noted at the start of step (5)).
</acceptance_criteria>
The phase is closed by evidence: the SPA and its UI backstops are unit-tested, one gate fails closed on any regression in either repository, the security review ties each threat to a test, the validation file is signed off, and the plugin is either published after the review on a published framework tag or its push is recorded as pending.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Test evidence → release decision | The gate's result is what the phase is verified against |
| Framework tree → consumers | Test fixtures and gate output must not name a consuming application |
| Plugin repository → host applications | A push of sm-user-plugin publishes its migrations, permission codes and admin screens to every project that mounts the shared plugin |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.1-35 | Repudiation | a gate that passes without measuring (zero tests, skips, a filter that matches nothing) | medium | mitigate | The go test -json detector refuses a failure, a skip, zero tests, non-JSON output and a missing named test; --self-test proves each on planted input (Tasks 1, 3). |
| T-12.1-36 | Information Disclosure | a consuming-application name in framework fixtures, tests, docs or gate output | low | mitigate | The hygiene stage scans the phase's framework files, the module READMEs, docs and admin/src (Task 3). |
| T-12.1-37 | Tampering | a mitigation silently removed by a later change | medium | mitigate | TestPhase121Threats in both repositories with one subtest per threat, the security stage's required name prefixes, and the removal harness proving each high or critical protection is load-bearing (Tasks 1, 3). |
| T-12.1-40 | Tampering | the shared plugin is published before the security review, or while the framework contract it builds on (tag v0.1.3, or a later framework fix) is not published | medium | mitigate | One push point for sm-user-plugin in the whole phase: step (5) of Task 3, after the gate and the review, and only when git ls-remote --tags origin v0.1.3 lists the tag and no framework production code changed after it; otherwise the push is skipped and recorded as pending, never forced. Plans 04 and 05 keep the application's pointer on local commits, and each of their verify commands fails when the plugin's head is on its origin while the tag is not (Task 3; plan 04 Task 4). |
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan; tests use only modules and packages already present. Any need for one stops at a blocking human checkpoint. |
| </threat_model> |
<success_criteria>
- ROADMAP success criterion 5 is met: the new code of both repositories has unit tests at or above the 80 percent floor, delivered in this last plan.
- The gate, the security review (zero open high or critical threats) and the validated validation file are in place before
/gsd-verify-work. - The takeover guard of D-30 is pinned by a threat subtest and a removal row for each of its two checks.
- The plugin is published only after the security review and only on a published framework tag, or its push is recorded as pending. </success_criteria>