docs(09): re-verify stale phase 09 report, gaps_found on fonoteka route inventory

This commit is contained in:
2026-10-11 15:03:11 +02:00
parent a3b502bfb9
commit 21fdcfcba6

View File

@@ -1,8 +1,8 @@
---
phase: 09-backend-admin-authentication-and-schema-pipeline
verified: 2026-10-05T15:08:52Z
status: passed
score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence)
verified: 2026-10-11T13:00:45Z
status: gaps_found
score: 5/5 roadmap success criteria verified; plan truths 53/54 (09-12 acceptance gate red at HEAD)
covered_files:
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-PLAN.md"
- ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-01-SUMMARY.md"
@@ -53,6 +53,7 @@ covered_files:
- "modules/cabana/crud.go"
- "modules/cabana/crud_lifecycle_test.go"
- "modules/cabana/crud_test.go"
- "modules/cabana/field_type_upload.go"
- "modules/cabana/filter_schema.go"
- "modules/cabana/form_schema.go"
- "modules/cabana/form_schema_test.go"
@@ -79,6 +80,8 @@ covered_files:
- "modules/cabana/settings.go"
- "modules/cabana/testdata/list/all_columns.yaml"
- "modules/cabana/testdata/list/all_filters.yaml"
- "modules/cabana/token_guard.go"
- "modules/cabana/token_scopes.go"
- "modules/cabana/tx_context.go"
- "modules/lagoon/backend_admin_migrations.go"
- "modules/lagoon/backend_admin_migrations_test.go"
@@ -89,46 +92,71 @@ covered_files:
- "modules/phrasebook/translator.go"
- "modules/surf/router.go"
- "scripts/check-phase9.sh"
covered_digest: "v2:sha256:c2f48f6ce04a5eed4788d745da45b1c1555a769df4feebfe88b9bbf671b634fb"
covered_files_note: "Fingerprint refreshed 2026-10-05 after later phases (12.1, 12.2, 13, 14) added to the same modules/cabana, lagoon, pact and surf files Phase 9 already covered. Paths remain modules/ (Phase 10.2). scripts/check-phase9.sh --all passed at summercms.go HEAD 104a1c9 and fonoteka.go HEAD 93b8b75. fonoteka.go files stay outside the digest and are listed under Required Artifacts."
covered_digest: "v2:sha256:372d2cbb7d7f2b63d4f43cbd6fe72a6eb916d2523159da5a96aa043e30d58448"
covered_files_note: "Regenerated 2026-10-11 at summercms.go HEAD a3b502b and fonoteka.go HEAD 7fc790d. Added modules/cabana/token_guard.go, token_scopes.go and field_type_upload.go: the 16.3 composite backend guard and the 16.2 field upload route now sit on the Phase 9 auth/gating path. fonoteka.go files stay outside the digest (separate repo) and are listed under Required Artifacts."
behavior_unverified: 0
overrides_applied: 0
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract."
mvp_mode_note: "ROADMAP marks Phase 9 mode: mvp, but the ROADMAP goal is not a User Story. Every Phase 9 PLAN carries a valid User Story, used for User Flow Coverage. The five ROADMAP success criteria are the contract."
re_verification:
previous_status: stale
previous_status: passed (stale, covered_changed)
previous_score: 5/5
reason: "The 2026-10-01T21:17Z passed report went stale because Phases 12.1, 12.2, 13 and 14 edited 25 already-covered implementation files (+3294/-217) after HEAD ba1aaef. This is content-digest drift, not missing modules/ paths (those were fixed in 1a878b3). scripts/check-phase9.sh --all passed at HEAD 104a1c9; covered_digest recomputed to match current bytes."
reason: "36 covered files changed after 104a1c9 (+1495/-161 in the previous covered set), mostly 14.2.1 (markdown/ML fields), 16.2 (plugin field types, field upload route), the My account/avatar work and 16.3 (personal API tokens, composite backend guard)."
gaps_closed: []
gaps_remaining: []
regressions: []
human_items_resolved:
- "Code-review triage: 09-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-19 as fixed (12 Info findings stay open, out of scope). Each fix is in the code at HEAD with a passing named test."
- "WR-11 decision (user, 2026-10-01): index added. Migration 202610010001_backend_users_email_ci_unique creates backend_users_email_lower_unique on lower(email), refuses to run on case-duplicate emails and names the logins; rollback drops the index. TestBackendAdminEmailCaseInsensitiveUnique and TestBackendAdminEmailIndexRefusesCaseDuplicates PASS."
- "WR-03 decision (user, 2026-10-01): single-record delete kept as Winter (allowed whenever a form exists). Code matches: operationDeclared 'delete' requires a form only; TestCRUDOperationsFollowDeclarations PASS."
- "WR-17 decision (user, 2026-10-01): exact-code deny does not remove a wildcard grant, as Winter's getMergedPermissions. Code matches applyUserPermissions; TestBackendUserPermissionsOverrideRole PASS."
- "Judgment-tier prohibitions (user, 2026-10-01): all 24 verdicts accepted; 09-03 #1 confirmed as superseded by Phase 10.1 D-09. 09-11 #1 (WR-02) and 09-12 #1 (WR-18) are not violated on code and test evidence."
- "09-UAT.md: status complete, 3/3 pass (ba1aaef)."
regressions:
- "Fonoteka TestPhase09SecurityRoutes (09-12 assembled route inventory) fails: 10 framework admin routes added after 104a1c9 are not listed in fonoteka's phase09AdminRoutes. scripts/check-phase9.sh --security exits 1. All 10 routes carry the backend guard; the failure is the fail-closed inventory doing its job, not an unguarded route."
gaps:
- truth: "09-12: The complete threat matrix executably prevents token crossover, missing permission, ... (Phase 9 fail-closed acceptance gate, scripts/check-phase9.sh --security / --all, is green)"
status: failed
reason: "fonoteka.go TestPhase09SecurityRoutes fails at HEAD 7fc790d with 'unexpected admin routes' for 10 routes added by later framework phases. The framework copy of the inventory (modules/cabana/security_coverage_test.go phase09Routes) was updated; the assembled app copy was not. check-phase9.sh --security exits 1, so --evidence and --all are red too."
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go"
issue: "phase09AdminRoutes lacks: PUT /account, POST /account/avatar, DELETE /account/avatar, GET /account/tokens, POST /account/tokens, GET /account/tokens/scopes, DELETE /account/tokens/{id}, GET /field-types, POST /markdown/preview, POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"
missing:
- "Review each of the 10 routes, then add them to fonoteka's phase09AdminRoutes as protected (public: false), mirroring modules/cabana/security_coverage_test.go phase09Routes (precedent: fonoteka commits 549840d, ca746fc)"
- "Re-run scripts/check-phase9.sh --all to green"
- "Optional hardening: the framework commits that add an admin route should also update the consumer inventory, or check-phase9.sh --security should be run before such a commit"
advisory:
- finding: "TestBackendTokenPostgres (Phase 16.3) fails 3/3 when run in the same process after TestBackendTokenGuard/TestTokenRouteGates/TestAccountTokens: 'token_postgres_test.go:125: last_used_at not set'. Passes alone."
category: other
reason: "Process-global lastStamp sync.Map in modules/cabana/api_tokens.go:281 is keyed by token id; ids restart at 1 in each fresh test database, so an earlier test's stamp debounces the later write. Test isolation defect in 16.3 scope, not a Phase 9 must-have. Can turn the full go test ./modules/cabana run red depending on order."
evidence_status: "reproduced: go test -count=1 ./modules/cabana -run '^(TestAllowsScope|TestBackendTokenGuard|TestBackendTokenPostgres|TestInteractiveOnly|TestResolveBackend|TestScopeCoversAll|TestScopeMatch|TestTokenRouteGates|TestTokenTracerPostgres|TestTokenGatesSmoke|TestAccountTokens)$'"
human_verified:
- test: "Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422)"
uat_row: 1
recorded_at: "2026-10-01T21:13:02.000Z"
- test: "Triage the 19 open code-review warnings (priority: WR-01, WR-02, WR-14, WR-17 for AUTH-08 Winter permission semantics)"
uat_row: 2
recorded_at: "2026-10-01T21:13:02.000Z"
- test: "Review the 24 judgment-tier prohibitions in 09-VERIFICATION.md"
uat_row: 3
recorded_at: "2026-10-01T21:13:02.000Z"
---
# Phase 9: Backend admin authentication and schema pipeline. Verification Report
**Phase Goal:** Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field.
**Verified:** 2026-10-05T15:08:52Z (summercms.go HEAD 104a1c9, fonoteka.go HEAD 93b8b75)
**Status:** passed
**Re-verification:** Yes. The 2026-10-01T21:17Z passed report went stale because later phases edited 25 already-covered implementation files. `scripts/check-phase9.sh --all` passed at HEAD 104a1c9; `covered_digest` was recomputed to match current bytes. Human items remain the 2026-10-01 decisions (09-UAT.md complete 3/3).
**Verified:** 2026-10-11T13:00:45Z (summercms.go HEAD a3b502b, fonoteka.go HEAD 7fc790d)
**Status:** gaps_found
**Re-verification:** Yes. The 2026-10-05 passed report went stale (covered_changed). This run re-checked each Phase 9 must-have against the later edits.
**MVP note:** ROADMAP marks this phase `mode: mvp`, but its goal is not a User Story. The PLAN files carry a valid one, which is used below. The five ROADMAP success criteria are the contract, and the plan `must_haves` are supporting evidence.
**Changes since the previous report that bear on Phase 9 (verified in code, not taken from 09-REVIEW-FIX.md):**
## Summary of this re-verification
- **Permissions (WR-01, WR-17):** `cabana.Allows` (`contracts.go:130`) now passes when ANY required code is granted, and `granted` (line 147) matches wildcard requirements (`x.*`, `*x`) as Winter's `hasPermission` does. `BackendUsers.FindByID` overlays the user's own `backend_users.permissions` on the role grants (`applyUserPermissions`, `auth.go:77`): `1` grants, `-1`/`0` remove an exact code.
- **Navigation (WR-02):** `Metadata` (`navigation.go:36`) drops a main item the principal may not open, whatever its children allow, and `openableTarget` (line 95) repoints an allowed parent to its first openable child. With Fonoteka's parent requirement `golem15.fonoteka.*`, a genres-only admin should now see the parent linked to `golem15.fonoteka.genres`, not albums (by code reading; the framework test pins this shape, no Fonoteka test asserts it).
- **Writes (WR-03, WR-04, WR-05):** `operationDeclared` (`http.go:806`) refuses create/update/delete/bulk-delete the compiled YAML does not declare (403). Form `required` applies only in contexts that can supply it. `relationMutation` (line 469) refuses link/unlink missing from `view.toolbarButtons`.
- **Auth (WR-10 to WR-14):** foreign `backend` guard fails boot (`bouncer.Registry.Owner`); ambiguous login identifiers answer the same opaque 401 (`findBackendLogin`, `auth.go:431`); dummy hash uses the configured bcrypt cost; `admin:*` read passwords from a prompt or stdin; logout runs outside the guard and revokes an expired-but-refreshable token via `bouncer.VerifyRefreshableClaimsAudience` (`refresh.go:60`).
- **Schema/query (WR-06 to WR-09, WR-16):** relation default sort is the first sortable column; relation column order comes from an ordered decode, not indentation; list search uses `LOWER(CAST(col AS TEXT))` (`query.go:296`); the scaffold declares a required permission and a record source; reflection helpers see embedded structs and explicit `column:` tags (`model_fields.go`).
- **Gate and transactions (WR-18, WR-19):** `check-phase9.sh` judges zero tests per package; hooks and scopes read the write transaction through `cabana.TxFromContext` (`tx_context.go:27`), used by Fonoteka's album and collection hooks.
- **Fonoteka (WR-15):** an admin editor link leaves `granted_by` NULL.
- **WR-11 index (2da8112, user decision):** migration `202610010001_backend_users_email_ci_unique` (`modules/lagoon/backend_admin_migrations.go`) checks for emails that differ only in case, fails naming the clashing logins, otherwise creates `backend_users_email_lower_unique ON backend_users (lower(email))`; rollback drops it. Registered through `BackendAdminMigrations` (`migrations.go:74`, plugin id `summercms.cabana`).
All five ROADMAP success criteria still hold in code and pass their tests at HEAD, including on real PostgreSQL. The later edits did not weaken the separate admin authentication, the backend JWT audience check, the permission registry, navigation or controller gating, the schema compilers or the relation manager.
One Phase 9 deliverable regressed: the assembled route inventory in fonoteka.go (`TestPhase09SecurityRoutes`, part of `check-phase9.sh --security`) is red. Later framework phases added 10 admin routes. They updated the framework's copy of the inventory but not the app's copy. Each of the 10 routes carries the `backend` guard, and the one under the controller path goes through `protect`. So the gate is failing closed as designed, and nothing is exposed. The fix is in fonoteka.go only, but until it lands the phase's own acceptance gate is red. That makes the status `gaps_found`, not `passed`.
### What the later edits changed on the Phase 9 path (read in code, not taken from SUMMARYs)
- **Composite backend guard (16.3).** `cabana.Activate` now mounts `newCompositeGuard` (`modules/cabana/token_guard.go:56`) under the name `backend`. A Bearer `g15_…` value goes to the personal-token path. Everything else goes to the unchanged `bouncer.NewBackendJWTGuard` (`modules/bouncer/jwt.go:94`, backend secret and audience). A token principal comes only from `BackendUsers.FindByID` (`auth.go:40`; activated `backend_users` rows only), so a token can never produce a frontend principal. The guard-ownership check (`bouncer.Registry.Owner`) still refuses a foreign `backend` guard.
- **Permission evaluation (16.3).** `Allows` (`contracts.go:192`) first applies a non-full token scope: `allowsUnderScope` (`token_scopes.go:246`) requires both the scope and the owner's grants (`ownerHas`), so a token only narrows and never widens. For a JWT session the branch is skipped and the Winter `hasAnyAccess` semantics are unchanged (`TestAllowsFollowsWinterHasAnyAccess` PASS). Account, token, refresh and logout routes are `interactiveOnly`. `/field-types` and `/markdown/preview` refuse scoped tokens via `tokenRouteGate`.
- **New admin routes.** `POST …/{id}/fields/{field}/upload` runs inside `protect` and then `operationDeclared` (`field_type_upload.go:32`). `/field-types` and `/markdown/preview` check `principal.Backend` and read no records. The `/account*` routes act on the signed-in admin only. All are under the `backend` middleware.
- **Navigation/settings.** `Metadata` (`navigation.go:39`) additionally hides a settings link entry whose controller the principal cannot open (`canOpen`, line 116). `Registry.Setting` no longer returns link entries, so the singleton endpoints answer 404 for them. Both changes are more restrictive.
- **Schema pipeline (14.2.1, 16.2).** New built-in types (`markdown`, `mltext`, `mltextarea`, `mlmarkdown`) and plugin field types resolved through a boot-time registry. Unknown keys are still rejected (`fieldKeyAllowed`, `DisallowUnknownField` at `form_schema.go:423`). Relation and settings forms refuse plugin types. Additive only.
- **CRUD.** ML and plugin-field values are lifted before projection and written inside the same transaction, after permission, scope and locks. `lagoon.Fill` still gates writes (`crud.go:637`, `settings.go:177`), and hooks still receive the write transaction.
- **Migrations.** `202610090001_backend_users_metadata` (nullable TEXT) and `202610110001_backend_api_tokens` (sha256 hash only, FK to `backend_users`) were added under the same `summercms.cabana` history. The earlier backend identity migrations are unchanged.
## User Flow Coverage
@@ -136,12 +164,12 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
| Step | Expected | Evidence | Status |
|---|---|---|---|
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS | VERIFIED |
| Log in separately | Backend login by login or email returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `modules/cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestAdminTracerGenreList`, `TestPhase09GuardIsolation`: PASS | VERIFIED |
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `modules/cabana/navigation.go` `Metadata`/`openableTarget`; `TestNavigationDropsDeniedParentAndRepointsTarget`, `TestAdminMetadataFiltering`: PASS | VERIFIED |
| Open a controller | 403 without the controller permission, before any schema or SQL work | `modules/cabana/http.go` `protect` (line 780), then `operationDeclared` for writes; `TestPhase09PermissionMatrix`, `TestCRUDOperationsFollowDeclarations`, `TestAdminTracerPermissionBoundary`, `TestPhase09SecurityRoutes`: PASS | VERIFIED |
| Work with schema-driven lists and forms | Form/list/relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; `TestPhase09AssembledAcceptance`: PASS on real PostgreSQL | VERIFIED |
| Outcome: permission-gated, reusable, decoupled | Framework has no app names; admin identity never touches frontend users | `grep -niE "fonoteka|collection_editors|granted_by|golem15_"` on non-test `modules/cabana/*.go`: no hits. `BackendUsers.FindByID` reads only `backend_users` | VERIFIED |
| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go` (now also appends `admin:token` commands); `TestAdminCreateCommand`, `TestAdminResetPasswordCommand`, `TestAdminPasswordWithoutFlag`, `TestAdminCreateRejectsCrossFieldCollision`: PASS on PostgreSQL | VERIFIED |
| Log in separately | Backend login returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `auth.go` `findBackendLogin` (line 417), `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestPhase09GuardIsolation`, Fonoteka `TestAdminTracerAuthBoundary`, `TestAdminTracerGenreList`: PASS | VERIFIED |
| See permitted navigation | `/navigation` lists only permitted items and never links to a 403 target | `navigation.go` `Metadata`/`openableTarget`/`canOpen`; `TestNavigationDropsDeniedParentAndRepointsTarget`, Fonoteka `TestAdminMetadata*`: PASS | VERIFIED |
| Open a controller | 403 without the controller permission, before any schema or SQL work | `http.go` `protect` (line 1067), `operationDeclared` (line 1093); `TestPhase09PermissionMatrix`, `TestCRUDOperationsFollowDeclarations`, Fonoteka `TestPhase09SecurityMatrix`, `TestAdminTracerPermissionBoundary`: PASS | VERIFIED |
| Work with schema-driven lists and forms | Form, list and relation schemas compiled from Winter YAML; CRUD, bulk delete, relation manager, settings | Sections below; Fonoteka `TestPhase09AssembledAcceptance` (via `check-phase9.sh --postgres`): PASS | VERIFIED |
| Outcome: permission-gated, reusable, decoupled | No app names in the framework; admin identity never touches frontend users | `grep -niE "fonoteka\|collection_editors\|granted_by\|golem15_"` on non-test `modules/cabana/*.go`: no hits. Token principals resolve only through `BackendUsers.FindByID` | VERIFIED |
## Goal Achievement
@@ -149,167 +177,160 @@ User story (from every 09-*-PLAN.md): *As a backend administrator, I want to aut
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate Winter-shaped `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdmin*` PASS in the full run). Separate `backend` guard with its own secret and required audience; cross-audience tokens fail both ways (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`: PASS); a foreign guard fails boot (`TestActivateRefusesAForeignBackendGuard`: PASS). Grants = role JSON + `HasPermissions` role assignments + user-level overlay (`auth.go:39-77`; `TestBackendUserPermissionsOverrideRole`: PASS on PostgreSQL). Every controller, relation and CRUD route goes through `protect` (`http.go:780`), settings through `protectSetting` (line 387). Permission matching follows Winter's `hasAnyAccess` (`TestAllowsFollowsWinterHasAnyAccess`, 17 cases: PASS). Navigation and settings filter by the same `Allows`; denied parents are dropped and targets repointed (`TestNavigationDropsDeniedParentAndRepointsTarget`: PASS). Also `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS. The previous WR-01/WR-02/WR-17 caveats are closed in code; WR-17's wildcard-deny behaviour was kept as Winter by user decision. |
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | `modules/cabana/form_schema.go` decodes with goccy/go-yaml and `yaml.DisallowUnknownField()` (line 286), walks the AST to keep order, rejects unknown keys and types. The real Fonoteka YAML covers every listed item. `required` now honours `context` (`TestCRUDRequiredFollowsContext`: PASS). Tests: `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestAlbumsAdminForm`, `TestAlbumsAdminDropdowns`, `TestStylesAdminForm`, `TestCollectionsAdminForm`: PASS. |
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | `modules/cabana/list_schema.go` column types `text`, `datetime`, `switch`; `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`schema_types.go:20-24`). Search/sort/filter resolve only through compiled allowlists with a PK tie-break; non-text searchable columns are cast to text (`query.go:296`; `TestListSearchNonTextColumns` on PostgreSQL: PASS). Tests: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. WR-08 caveat closed. |
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `fonoteka.go/.../models/collection/fields.yaml:19` `editors: type: relation-manager`; no `partial` in the Fonoteka model YAML. `config_relation.yaml` has view/manage list columns, `toolbarButtons: link\|unlink`, `showSearch`. `modules/cabana/relation.go` serves schema, linked, candidates (`RelationExtendManageQuery` at line 473), link and unlink (`RelationBeforeLink` at line 671); link/unlink now require the panel's `toolbarButtons` (`TestRelationMutationsFollowToolbarButtons`: PASS); column order is indentation-independent (`TestRelationColumnOrderIsIndependentOfIndentation`: PASS); default sort is the first sortable column (`TestRelationDefaultSort`: PASS). Fonoteka: `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial` (legacy path-less partial fails boot), `TestRelationCandidateExclusions`: PASS. **Note:** Phase 10.1 D-09 lifted Phase 9's blanket `type: partial` boot error for a bare-name sanitized html/template partial (`compilePartialPath`, `form_schema.go:508`); the Collections editors tab is still a relation manager. WR-05, WR-07, WR-15 caveats closed. |
| 5 | Admin CRUD endpoints expose listExtendQuery/formExtendQuery/formBeforeCreate/formBeforeUpdate/relationExtendManageQuery hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | Hook interfaces in `modules/pact/capabilities.go:331-394`, type-asserted in `modules/cabana/query.go:109`, `crud.go:445,534,581,597`, `relation.go:473`. Hooks receive the write transaction through `TxFromContext` (`TestHooksReceiveTheWriteTransaction`: PASS; Fonoteka `TestAlbumsAdminHooksUseTheWriteTransaction` with a one-connection pool: PASS). `CRUDService.BulkDelete` (`crud.go:187`) locks scoped rows in one transaction and deletes per row via `deleteRecord`, so hooks fire per record; it now requires `delete` in `toolbar.buttons`. `TestBulkDeleteDuplicates`, `Idempotent`, `Rollback`, `TestCRUDHooks`: PASS. Settings use the same `Localize`, writable projection, `lagoon.Fill` (`settings.go:149`) and `lagoon.Validate`; `TestAdminSettings*` on PostgreSQL: PASS. CR-01 stays fixed (`TestCRUDFillTypeIsValidation`: PASS). |
| 1 | A backend admin user with a role logs in separately from frontend users, and navigation/controller access is gated by the permissions registry. | ✓ VERIFIED | Separate `backend_users`/`backend_user_roles` (`modules/lagoon/backend_admin_migrations.go`; 7 `TestBackendAdmin*` PASS on PostgreSQL, including the new `MetadataColumn`). Separate `backend` guard with its own secret and required audience, now wrapped by the composite guard, which hands every non-`g15_` request to the unchanged backend JWT guard. A frontend-audience token and a wrong-secret token get 401 on every Fonoteka admin path (`TestPhase09SecurityMatrix` PASS), and cross-audience fails both ways (`TestPhase09GuardIsolation` PASS). A foreign `backend` guard still fails boot (`TestActivateRefusesAForeignBackendGuard`, `TestRegistryOwner`: PASS). Grants = role + `HasPermissions` role assignments + user overlay (`auth.go:40-69`, `applyUserPermissions` line 78, unchanged; `TestBackendUserPermissionsOverrideRole` PASS). Every controller route, including the new field upload, goes through `protect`, and settings through `protectSetting` (line 542). Personal API tokens narrow owner grants and never widen them (`TestAllowsScope`, `TestBackendTokenGuard`, `TestTokenRouteGates`, `TestInteractiveOnly`, `TestResolveBackend`, `TestTokenTracerPostgres`: PASS). |
| 2 | `fields.yaml` for a real controller parses (goccy/go-yaml) into a JSON form schema covering text, textarea, checkbox, switch, dropdown (model-method options) and relation (nameFrom, emptyOption), with span/tabs/context/attributes layout hints. | ✓ VERIFIED | `form_schema.go` decodes with goccy/go-yaml and `DisallowUnknownField` (line 423) and walks the AST to keep order. New types are additive, and plugin types resolve only through the boot-time registry with strict per-type option keys. `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestCRUDRequiredFollowsContext`: PASS. Fonoteka `TestAlbumsAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`: PASS. |
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | `list_schema.go` keeps `text`/`datetime`/`switch`. A plugin column type is accepted only when registered (`compileColumns` still refuses unknown types). Search and sort go through allowlists, and non-text columns are cast (`query.go:497`). `TestListSchemaCompile`, `TestListSearchNonTextColumns` (PostgreSQL): PASS. Fonoteka `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestGenresAdmin*`: PASS. |
| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `fonoteka.go/.../models/collection/fields.yaml:19` `editors: type: relation-manager`; no `partial` under Fonoteka `models/` (grep). `relation.go` `RelationExtendManageQuery` at lines 670 and 707; `relationMutation` (`http.go:626`) still requires `toolbarButtons`. `TestRelationMutationsFollowToolbarButtons`, `TestRelationColumnOrderIsIndependentOfIndentation`, `TestRelationDefaultSort`, `TestRelationCandidateExclusions`: PASS. Fonoteka `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial`: PASS. The Phase 10.1 D-09 sanitized partial (`compilePartialPath`, `form_schema.go:759`) is unchanged and was accepted by the user. |
| 5 | Admin CRUD endpoints expose `listExtendQuery`/`formExtendQuery`/`formBeforeCreate`/`formBeforeUpdate`/`relationExtendManageQuery` hooks, bulk delete runs each record's lifecycle hooks, and the Settings model binds to a settings screen through the same schema pipeline. | ✓ VERIFIED | Interfaces at `modules/pact/capabilities.go:579,615,620,625,676`. They are type-asserted in `query.go:116`, `crud.go:806,904,951,967` and `relation.go:670,707`. `BulkDelete` (`crud.go:283`) runs per-row `deleteRecord` in one transaction (lines 268, 322). Settings use `lagoon.Fill` (`settings.go:177`). `TestBulkDelete{Duplicates,Idempotent,Rollback,Order,Empty,Concurrent}`, `TestCRUDHooks`, `TestHooksReceiveTheWriteTransaction`, `TestCRUDFillTypeIsValidation`: PASS on PostgreSQL. Fonoteka `TestAdminSettings*` (8), `TestAlbumsAdminHooksUseTheWriteTransaction`: PASS. |
**Score:** 5/5 ROADMAP truths verified (0 present-but-behavior-unverified).
### Plan must-have truths (supporting evidence)
There are 54 plan truths across 09-01 to 09-12, and each maps to a named test in its plan's `<verify>` block. I re-ran every named Fonoteka suite with `-v`: 79 top-level PASS, 0 SKIP, 0 FAIL. That equals the number of test functions with those prefixes in the package (79, now including `TestAlbumsAdminHooksUseTheWriteTransaction` from the WR-19 fix). The 20 tests added by the summercms.go fix commits were run by name and all pass.
There are 54 plan truths across 09-01 to 09-12. Fonoteka's named Phase 9 suites at HEAD (`-run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*|TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled|TestRelationCandidateExclusions)$'`) gave 78 PASS, 1 FAIL, 0 SKIP. The failure is `TestPhase09SecurityRoutes`.
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to the framework-owned `summercms.go/admin/openapi/admin.json`. The intent still holds: `scripts/check-admin-openapi.sh --check` and `scripts/check-phase9.sh --openapi` exit 0.
| Truth | Status | Evidence |
|---|---|---|
| 09-12 #2: complete threat matrix executable, enforced by the fail-closed Phase 9 gate | ✗ FAILED | `check-phase9.sh --security` exit 1: `refuse: go test ./plugins/golem15/fonoteka -run ^TestPhase09Security`. `admin_phase09_security_test.go:52`: "unexpected admin routes" lists 10 routes, each with `Middleware: [backend]`. The framework half (`go test ./modules/bouncer ./modules/cabana -run ^TestPhase09`) passes. |
| All other 53 plan truths | ✓ VERIFIED | Named tests above and in the Spot-Checks table pass; `check-phase9.sh --self-test`, `--postgres` and `--openapi` pass. |
Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 moved the admin paths to `summercms.go/admin/openapi/admin.json`. `scripts/check-admin-openapi.sh --check` exits 0, and so does `check-phase9.sh --openapi`.
Backstop (non-inferable) truths:
| Truth | Evidence | Status |
|---|---|---|
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (frontend user with the admin's email; frontend password rejected), `TestLoginAmbiguousIdentifier` (a cross-field collision now fails opaquely instead of taking the first match), `TestMissingUserHashUsesConfiguredCost`: PASS | VERIFIED (WR-11 closed; `lower(email)` unique index added, `TestBackendAdminEmailCaseInsensitiveUnique` PASS) |
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate`: PASS | VERIFIED |
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables, `Principal.Backend` flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal`, `TestVerifyRefreshableClaimsAudience` (refresh-window verification keeps the audience check): PASS | VERIFIED |
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestMissingUserHashUsesConfiguredCost` PASS; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) PASS. `lower(email)` unique index tests PASS | VERIFIED |
| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | Fonoteka `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate` PASS | VERIFIED |
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables. `Principal.Backend` is set only by `principalFrom`, and personal API tokens live in `backend_api_tokens` (FK to `backend_users`, distinct `g15_` prefix) and resolve only to backend principals. `TestPhase09GuardIsolation`, `TestVerifyRefreshableClaimsAudience`, `TestBackendTokenGuard` PASS | VERIFIED |
### Prohibitions (judgment tier, non-authoritative verdicts)
| Plan | Prohibition | Verdict |
The 24 verdicts accepted in UAT row 3 still stand at HEAD. Re-checked against the later edits:
| Plan | Prohibition | Verdict at HEAD |
|---|---|---|
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (foreign `backend` guard now fails boot) |
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every route; logout is public by design, CSRF-checked, and only revokes the presented token) |
| 01 | Tracer must not be mock-only | not violated (testcontainers PostgreSQL, assembled router) |
| 02 | No boot, web-wizard or env-seeded first admin | not violated (migration seeds roles only; `admin:create` only) |
| 02 | No cookie session store and no merge with the frontend user model | not violated |
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`TestAdminAuthLogging`, `TestPhase09SecurityCoverage`; the `--password` deprecation warning never echoes the value) |
| 03 | Form compiler must not accept `type: partial` | **superseded (accepted by the user 2026-10-01)**: Phase 10.1 D-09 lifted this for a supported partial contract (bare-name `path`, html/template, allowlisted node renderer, D-18 sanitizer). The legacy Winter path-less partial is still refused (`TestCollectionsAdminRejectsPartial`). |
| 03 | Labels not deferred to the client or cached in the first request's locale | not violated (per-request `Localize`) |
| 03 | Unknown keys, types or providers not silently ignored | not violated (`DisallowUnknownField`, `formFieldKeys`) |
| 04 | YAML must not inject SQL or name an arbitrary method | not violated (`conditions:` rejected, finite `FilterScopes`; search cast is on an allowlisted, quoted column) |
| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break) |
| 05 | No partially committed bulk operation | not violated (`TestBulkDeleteRollback`) |
| 05 | Replay must not rerun destructive hooks | not violated (`TestBulkDeleteIdempotent`) |
| 06 | No cross-collection or silently chosen duplicate user on albums | not violated (`TestAlbumsAdminAmbiguousEmail`, `CrossCollection`) |
| 06 | Album form choices must not expose inactive or cross-collection users | not violated |
| 07 | Artist parity not reached by silently omitting Winter keys | not violated |
| 08 | No second Genre identity and no weakened permission | not violated (`TestGenresAdminTracerIdentity`, `DuplicateRegistration`) |
| 09 | Style values not coerced between scalar types | not violated (`TestStylesAdminTypedOptions`) |
| 10 | No PHP partial and no hardcoded Fonoteka pivot names in the framework | not violated (non-test `modules/cabana/*.go` grep: no hits) |
| 10 | Owner, cross-collection or already-linked candidates not linkable by forgery | not violated (`ForgedPivot`, `CrossScope`, `Idempotent`; link now also requires the declared toolbar button) |
| 11 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **not violated** (was qualified): a denied main item is dropped; an allowed parent never links to a controller the admin cannot open (`TestNavigationDropsDeniedParentAndRepointsTarget` pins the same parent `x.*` + genres-only shape; for Fonoteka this follows from code reading, since no Fonoteka test asserts a genres-only menu) |
| 11 | Reading a missing singleton must not create a row | not violated (`TestAdminSettingsMissingRead`) |
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | **not violated** (was qualified): `phase9_detect` refuses any package without a passing test; `--self-test` re-run prints "refuse: zero tests in a/cabana" for the planted case and passes |
| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md`; `check-phase9.sh --security` re-run: "phase9 security passed") |
| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated (composite guard wraps the backend JWT guard with the backend secret; tokens resolve to `backend_users` only) |
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every controller and settings route, including the new field upload) |
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`logTokenAuth` logs outcome, method, path, remote and ids only; `TestPhase09SecurityCoverage` PASS) |
| 03 | Form compiler must not accept `type: partial` | superseded (accepted 2026-10-01) by Phase 10.1 D-09; legacy path-less partial still refused |
| 03 | Unknown keys, types or providers not silently ignored | not violated (plugin types extend the allowlist explicitly; unknown types still fail boot) |
| 11 | Navigation/settings metadata must not reveal inaccessible entries | not violated, and tighter: settings link entries are hidden when their controller cannot be opened |
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | not violated (the gate refuses, and is refusing now on the stale inventory) |
| others | (see 2026-10-05 report) | unchanged; no later edit touches them |
### Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `modules/lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed, case-insensitive unique email | ✓ VERIFIED | Explicit SQL up/down; `TestBackendAdmin*` (6 incl. `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) PASS |
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user-level permission overlay | ✓ VERIFIED | `BackendUsers` reads only `backend_users` |
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | prompt/stdin password, collision checks |
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | Mounted from `modules/surf/router.go:522` via `cabana.Activate` |
| `modules/cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | |
| `modules/cabana/list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | list compiler, allowlisted query, column resolution | ✓ VERIFIED | |
| `modules/cabana/crud.go`, `tx_context.go` | CRUD, projection, hooks, bulk delete, tx on context | ✓ VERIFIED | |
| `modules/lagoon/fill.go` | allowlisted fill used by CRUD and settings | ✓ VERIFIED | |
| `modules/lagoon/backend_admin_migrations.go` | backend users/roles, seed, case-insensitive email, now metadata + api tokens | ✓ VERIFIED | 7 `TestBackendAdmin*` PASS |
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user overlay | ✓ VERIFIED | `FindByID` unchanged apart from `me` profile helper |
| `modules/cabana/token_guard.go`, `token_scopes.go` | composite guard (16.3) on the Phase 9 gate path | ✓ VERIFIED | Narrow-only scope; tests PASS (one isolation defect, see Advisory) |
| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | |
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | Mounted from `modules/surf/router.go:548` via `cabana.Activate` |
| `modules/cabana/form_schema.go`, `schema_types.go`, `list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | strict compilers, allowlisted query | ✓ VERIFIED | |
| `modules/cabana/crud.go`, `tx_context.go`, `modules/lagoon/fill.go` | CRUD, hooks, bulk delete, tx on context | ✓ VERIFIED | |
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | |
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | |
| `modules/bouncer/refresh.go`, `registry.go` | refresh-window verification for logout; guard ownership | ✓ VERIFIED | |
| `scripts/check-phase9.sh` | fail-closed gate, per-package zero-test check | ✓ VERIFIED | `--self-test`, `--openapi`, `--security`: exit 0 |
| `fonoteka.go/.../controllers/{albums,artists,collections,genres,styles}_admin_controller.go`, `controllers/request_db.go` | five controllers with permissions; hooks read the write tx | ✓ VERIFIED | |
| `fonoteka.go/.../models/*/fields.yaml`, `columns.yaml`, `controllers/*/config_*.yaml` | Winter-shaped YAML | ✓ VERIFIED | `partial` replaced by `relation-manager` |
| `fonoteka.go/.../admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | registerPermissions/Navigation/Settings ports | ✓ VERIFIED | Parent requires `golem15.fonoteka.*` |
| `fonoteka.go/.../admin_phase09_e2e_test.go`, `admin_phase09_security_test.go` | assembled acceptance and route inventory | ✓ VERIFIED | Logout listed as public with reason |
| `modules/bouncer/jwt.go`, `refresh.go`, `registry.go` | audience guard, refresh-window verification, ownership | ✓ VERIFIED | `jwt.go`, `refresh.go`, `registry.go` unchanged since 104a1c9 |
| `modules/cabana/security_coverage_test.go` | framework route inventory | ✓ VERIFIED | Lists all 10 new routes; `TestPhase09SecurityCoverage` PASS |
| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED (artifact) / ✗ gate red | `--self-test`, `--postgres`, `--openapi` exit 0; `--security` exit 1 |
| `fonoteka.go/.../admin_phase09_security_test.go` | assembled route inventory | ✗ STALE | Missing the 10 later routes; see Gaps |
| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS |
| `fonoteka.go/.../controllers/*_admin_controller.go`, model YAML, `admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | five controllers and registries | ✓ VERIFIED | Unchanged since 93b8b75 (fonoteka drift since then is Phase 14.1 frontend `/me` only) |
### Key Link Verification
| From | To | Via | Status |
|---|---|---|---|
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 522 | WIRED |
| `modules/cabana/http.go` guard | `modules/bouncer/jwt.go` | `NewBackendJWTGuard` with backend audience; ownership via `Registry.Owner` | WIRED |
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` then blacklist jti | WIRED |
| `modules/cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)`, `operationDeclared`, then list/form/relation | WIRED |
| `modules/cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction (line 290; Fill at 327, `FillTypeError` to 422) | WIRED |
| `modules/cabana/crud.go` | plugin hooks | `TxFromContext` inside `lagoon.Transaction` | WIRED |
| `modules/cabana/crud.go` bulk | model lifecycle hooks | per-row `deleteRecord` inside one transaction | WIRED |
| `modules/cabana/settings.go` | form pipeline | `Localize`, `Fill` (line 149), `Validate` | WIRED |
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 548 | WIRED |
| `cabana` `backend` guard | `modules/bouncer/jwt.go` | `newCompositeGuard(NewBackendJWTGuard(secret, …))`; ownership via `Registry.Owner` | WIRED |
| composite guard token path | `backend_users` | `users.FindByID(row.BackendUserID)` (`token_guard.go`) | WIRED |
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` (line 60) | WIRED |
| handlers | compiled schemas | `protect` then `s.reg.Get(id)`, `operationDeclared` | WIRED |
| `crud.go` | `lagoon.Fill`/`Validate`, plugin hooks | `save` transaction, `TxFromContext` | WIRED |
| `crud.go` bulk | lifecycle hooks | per-row `deleteRecord` | WIRED |
| `settings.go` | form pipeline | `Localize`, `Fill` (line 177), `Validate` | WIRED |
| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED |
| `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED |
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (assembled stage red) |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|---|---|---|---|---|
| List endpoint | `data` rows | GORM query on the registered model, scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
| Navigation | entries | plugin `Navigation()` filtered by role + user-level grants from `backend_user_roles`/`backend_users` | Yes | ✓ FLOWING |
| Settings GET | `data` | `golem15_fonoteka_settings` row or compiled defaults | Yes | ✓ FLOWING |
| Relation linked/candidates | rows | pivot-joined user query with owner and linked users excluded | Yes | ✓ FLOWING |
| List endpoint | `data` rows | GORM query scoped by `ListExtendQuery` | Yes (PostgreSQL rows in assembled tests) | ✓ FLOWING |
| Navigation | entries | plugin `Navigation()` filtered by role + user grants (+ token scope) | Yes | ✓ FLOWING |
| Settings GET | `data` | singleton row or compiled defaults | Yes | ✓ FLOWING |
| Relation linked/candidates | rows | pivot-joined user query | Yes | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| Framework vet | `go vet ./...` (summercms.go) | exit 0, no output | ✓ PASS |
| Framework regression (single full run) | `go test ./... -count=1` (summercms.go) | exit 0; 35 packages ok (plus packages with no test files), 0 FAIL | ✓ PASS |
| App vet | `go vet $(go list -f '{{.Dir}}/...' -m)` (fonoteka.go, all workspace modules) | exit 0 | ✓ PASS |
| App regression | `go test $(go list -f '{{.Dir}}/...' -m) -count=1` (fonoteka.go, root + user + fonoteka plugin modules) | exit 0; 13 packages ok, 0 FAIL | ✓ PASS |
| Phase 9 framework tests, named | `go test ./modules/cabana -v -run '^(TestAllowsFollowsWinterHasAnyAccess\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestRelationMutationsFollowToolbarButtons\|TestBulkDelete(Duplicates\|Idempotent\|Rollback)\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestRelationCandidateExclusions\|TestPhase09PermissionMatrix\|TestAdminAuthLifecycle\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 16 PASS | ✓ PASS |
| Review-fix tests, named | `go test ./modules/cabana ./modules/bouncer -v -run '^(TestActivateRefusesAForeignBackendGuard\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestAdminPasswordWithoutFlag\|TestBackendUserPermissionsOverrideRole\|TestCRUDOperationsFollowDeclarations\|TestCRUDRequiredFollowsContext\|TestFieldByColumnTagBeatsGoName\|TestHooksReceiveTheWriteTransaction\|TestListSearchNonTextColumns\|TestLoginAmbiguousIdentifier\|TestMissingUserHashUsesConfiguredCost\|TestModelHelpersLookThroughEmbeddedStructs\|TestRegistryOwner\|TestRelationColumnOrderIsIndependentOfIndentation\|TestRelationDefaultSort\|TestVerifyRefreshableClaimsAudience)$'` | 17 PASS (WR-09 scaffold assertions run inside `TestScaffoldAllArtifacts`, full run PASS) | ✓ PASS |
| Guard isolation | `go test ./modules/bouncer -run '^TestPhase09GuardIsolation$' -v` | PASS | ✓ PASS |
| Backend admin migrations incl. WR-11 index | `go test ./modules/lagoon -count=1 -v -run '^(TestBackendAdmin.*)$'` | 6 PASS (`Migration`, `Seed`, `Rollback`, `WinterRow`, `EmailCaseInsensitiveUnique`, `EmailIndexRefusesCaseDuplicates`) | ✓ PASS |
| Assembled Phase 9 acceptance and controllers | `go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestPhase09.*\|TestAdminSettings.*\|TestAdminMetadata.*\|TestCollectionsAdmin.*\|TestAlbumsAdmin.*\|TestGenresAdmin.*\|TestArtistsAdmin.*\|TestStylesAdmin.*\|TestAdminTracer.*\|TestAdminAuthLifecycleAssembled)$'` (fonoteka.go) | exit 0; 79 PASS, 0 SKIP, 0 FAIL | ✓ PASS |
| Framework vet + short suite | orchestrator: `go vet ./...`, `go test -short ./...` | clean; 38 packages ok | ✓ PASS |
| Phase 9 framework tests (short) | `go test -short -count=1 ./modules/cabana ./modules/bouncer -run '^(TestAllowsFollowsWinterHasAnyAccess\|TestNavigationDropsDeniedParentAndRepointsTarget\|TestRelationMutationsFollowToolbarButtons\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestPhase09PermissionMatrix\|TestActivateRefusesAForeignBackendGuard\|TestFieldByColumnTagBeatsGoName\|TestMissingUserHashUsesConfiguredCost\|TestModelHelpersLookThroughEmbeddedStructs\|TestRegistryOwner\|TestRelationColumnOrderIsIndependentOfIndentation\|TestRelationDefaultSort\|TestVerifyRefreshableClaimsAudience\|TestPhase09GuardIsolation\|TestPhase09SecurityCoverage\|TestPhase09ContractInventory\|TestRelationCandidateExclusions)$'` | 19 PASS | ✓ PASS |
| Phase 9 framework tests (PostgreSQL) | `go test -count=1 ./modules/cabana ./modules/lagoon -run '^(TestLoginAmbiguousIdentifier\|TestHooksReceiveTheWriteTransaction\|TestCRUDRequiredFollowsContext\|TestCRUDOperationsFollowDeclarations\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestBulkDelete.*\|TestAdminPasswordWithoutFlag\|TestAdminCreateRejectsCrossFieldCollision\|TestAdminAuthLifecycle\|TestAdminLogoutRevokesExpiredRefreshableToken\|TestBackendUserPermissionsOverrideRole\|TestListSearchNonTextColumns\|TestBackendAdmin.*\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 27 PASS | ✓ PASS |
| 16.3 guard on the Phase 9 path | `go test -count=1 ./modules/cabana -run '^(TestAllowsScope\|TestBackendTokenGuard\|TestInteractiveOnly\|TestResolveBackend\|TestScopeCoversAll\|TestScopeMatch\|TestTokenRouteGates\|TestTokenTracerPostgres\|TestTokenGatesSmoke\|TestAccountTokens\|TestBackendTokenPostgres)$'` | 10 PASS, `TestBackendTokenPostgres` FAIL (passes alone; isolation defect, see Advisory) | ⚠️ (out of Phase 9 scope) |
| Assembled Phase 9 suites | Fonoteka named suites (above) | 78 PASS, 1 FAIL (`TestPhase09SecurityRoutes`) | ✗ FAIL |
### Probe Execution
No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase gate ran instead:
No `scripts/*/tests/probe-*.sh` exists and no plan declares one. The phase gate ran instead:
| Probe | Command | Result | Status |
|---|---|---|---|
| `scripts/check-phase9.sh` | `--self-test` | planted cases refused ("skipped TestPhase09MigrationsFreshRollback", "zero tests", "failed TestPhase09ContractInventory", "zero tests in a/cabana"), then "phase9 self-test passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--self-test` | planted cases refused; "phase9 self-test passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--security` | framework stage passes; `refuse: go test ./plugins/golem15/fonoteka -run ^TestPhase09Security … (test=1 detect=1)`, exit 1 | FAILED |
| `scripts/check-phase9.sh` | `--postgres` | "phase9 postgres passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--openapi` | "phase9 openapi passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--security` | "phase9 security passed", exit 0 | PASS |
| `scripts/check-admin-openapi.sh` | `--check` | exit 0 | PASS |
`--evidence` and `--all` call `run_security` and are therefore red as well; they were not run separately.
### Requirements Coverage
| Requirement | Source Plans | Description | Status | Evidence |
|---|---|---|---|---|
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED | Truth 1 |
| AUTH-08 | 09-01, 09-02, 09-11, 09-12 | Backend admins with roles and a permission registry, separate from frontend users, gating navigation and controllers | ✓ SATISFIED (behavior); acceptance inventory red | Truth 1; gap below |
| ADMIN-01 | 09-03, 09-06..09-10, 09-12 | fields.yaml to JSON form schema | ✓ SATISFIED | Truth 2 |
| ADMIN-02 | 09-01, 09-04, 09-06..09-10, 09-12 | columns.yaml to JSON list schema | ✓ SATISFIED | Truth 3 |
| ADMIN-03 | 09-10, 09-12 | relation-manager replaces `partial` | ✓ SATISFIED | Truth 4 |
| ADMIN-04 | 09-05..09-10, 09-12 | CRUD hooks and per-record bulk delete | ✓ SATISFIED | Truth 5 |
| ADMIN-05 | 09-11, 09-12 | settings model bound through the same pipeline | ✓ SATISFIED | Truth 5 |
REQUIREMENTS.md maps exactly these six IDs to Phase 9, so there are no orphaned requirements.
REQUIREMENTS.md maps exactly these six IDs to Phase 9; no orphaned requirements.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
| `internal/build/stubs/artifacts.tmpl` | 106 | `// TODO: return a pointer to the plugin's model` in the generated controller | ℹ️ Info | Deliberate scaffold placeholder (WR-09); generated text, not a debt marker in phase code. Until filled in, the scaffold answers 500 and boot refuses the undeclared permission |
| `modules/cabana/settings.go` | 149 | no test writes a numeric settings field | ℹ️ Info | The `json.Number` conversion lives in the shared `lagoon.Fill`; settings Fill failures already answer 422 |
| `fonoteka.go/.../admin_phase09_security_test.go` | 52 | stale route inventory | 🛑 Blocker (evidenced: named test red) | Phase 9 acceptance gate red |
| `modules/cabana/api_tokens.go` | 281 | process-global debounce map keyed by token id | ℹ️ Info (16.3 scope) | `TestBackendTokenPostgres` order-dependent |
| `internal/build/stubs/artifacts.tmpl` | — | `// TODO: return a pointer to the plugin's model` in generated scaffold | ℹ️ Info | Deliberate scaffold placeholder (WR-09) |
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits). The 12 Info review findings (IN-01..IN-12) remain `open` in the ledger and are out of the fix run's scope; none defeats a success criterion.
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits).
### Human Verification Required
None open. The previous items are resolved by the user's recorded decisions on 2026-10-01 (09-REVIEW-FIX.md "Decisions", 09-UAT.md status complete, 3/3 pass, commit ba1aaef), and the code at HEAD matches each decision:
None. All three 09-UAT.md rows (recorded 2026-10-01T21:13:02Z) were decisions and triage, not runtime observations. Covered files changed after the UAT, but none of the changes alters the code those decisions rest on:
| Item | Decision | Code evidence |
| UAT row | Rests on | Still holds at HEAD |
|---|---|---|
| WR-11 unique index on `lower(email)` | add it | migration `202610010001_backend_users_email_ci_unique`; `TestBackendAdminEmailCaseInsensitiveUnique`, `TestBackendAdminEmailIndexRefusesCaseDuplicates` PASS |
| WR-03 single-record delete | keep as Winter (allowed whenever a form exists) | `operationDeclared` in `modules/cabana/http.go`; `TestCRUDOperationsFollowDeclarations` PASS |
| WR-17 deny versus wildcard | keep as Winter (exact-code merge) | `applyUserPermissions` in `modules/cabana/auth.go`; `TestBackendUserPermissionsOverrideRole` PASS |
| 24 judgment-tier prohibitions | verdicts accepted; 09-03 #1 superseded by Phase 10.1 D-09 | Prohibitions table above |
| 1 CR-01 | Fill type errors answer 422 | `crud.go:637-640` unchanged in behavior; `TestCRUDFillTypeIsValidation` PASS |
| 2 WR-01..WR-19 triage (WR-03, WR-11, WR-17 decisions) | `operationDeclared` delete rule; `lower(email)` index; `applyUserPermissions` exact-code merge | `TestCRUDOperationsFollowDeclarations`, `TestBackendAdminEmailCaseInsensitiveUnique`, `TestBackendAdminEmailIndexRefusesCaseDuplicates`, `TestBackendUserPermissionsOverrideRole`, `TestAllowsFollowsWinterHasAnyAccess`, `TestNavigationDropsDeniedParentAndRepointsTarget` PASS; `applyUserPermissions` unchanged |
| 3 24 prohibitions | see Prohibitions table | all re-checked; none newly violated |
They are recorded under `human_verified` and are not re-requested.
### Gaps Summary
No ROADMAP success criterion failed, so there are no gaps. Separate admin authentication, permission gating of controllers, settings and navigation, the strict goccy/go-yaml form and list pipeline, the relation manager that replaces the Collections editors `partial`, the CRUD hooks, per-record bulk delete and the settings binding all exist under `modules/`, are wired, and pass their tests on real PostgreSQL at HEAD in both repositories. CR-01 and all 19 review warnings are fixed in code, and each fix has a passing named test.
The phase goal is achieved in code. Later phases changed the auth path (composite guard, personal API tokens, scope-aware `Allows`) and added routes, but every change keeps backend identity separate, keeps the backend JWT audience check, only narrows grants, and puts new controller routes behind `protect`. The schema pipeline changes are additive and still strict.
The three policy choices from the fix run are decided (WR-11 index added and tested; WR-03 and WR-17 kept as Winter), the judgment-tier prohibition verdicts are accepted, and UAT is complete. No truth failed, no human item is open, so the phase is `passed`.
One Phase 9 deliverable is broken: the app-side copy of the assembled route inventory (`fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go`, `phase09AdminRoutes`) lacks 10 routes added by later framework work (My account, avatar, personal API tokens, field types, markdown preview, field upload). `TestPhase09SecurityRoutes` and therefore `check-phase9.sh --security`, `--evidence` and `--all` fail. The fix is test-only and lives in fonoteka.go: review the 10 routes and list them as protected, as commits 549840d and ca746fc did for 12.1/12.2. No framework change is needed. The summercms.go root `go test ./...` does not run Fonoteka tests, so this did not show up in the framework suite.
Separately, and outside Phase 9's scope, `TestBackendTokenPostgres` (16.3) is order-dependent because of the process-global `lastStamp` debounce. It may turn the Docker-backed `go test ./modules/cabana` red, so it is worth a 16.3 follow-up.
---
_Verified: 2026-10-01T21:17:13Z_
_Verifier: Claude (gsd-verifier)_
_Verified: 2026-10-11T13:00:45Z_
_Verifier: Claude (msd-verifier)_