docs(09): re-verify after quick-261011-kzi, phase 09 passed

This commit is contained in:
2026-10-11 15:40:54 +02:00
parent be82bccfc3
commit 07e579a264

View File

@@ -1,8 +1,8 @@
---
phase: 09-backend-admin-authentication-and-schema-pipeline
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)
verified: 2026-10-11T13:38:34Z
status: passed
score: 5/5 roadmap success criteria verified; plan truths 54/54
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"
@@ -42,6 +42,7 @@ covered_files:
- "modules/bouncer/registry.go"
- "modules/bouncer/registry_test.go"
- "modules/cabana/admin_openapi.go"
- "modules/cabana/api_tokens.go"
- "modules/cabana/auth.go"
- "modules/cabana/auth_internal_test.go"
- "modules/cabana/auth_test.go"
@@ -81,6 +82,7 @@ covered_files:
- "modules/cabana/testdata/list/all_columns.yaml"
- "modules/cabana/testdata/list/all_filters.yaml"
- "modules/cabana/token_guard.go"
- "modules/cabana/token_guard_test.go"
- "modules/cabana/token_scopes.go"
- "modules/cabana/tx_context.go"
- "modules/lagoon/backend_admin_migrations.go"
@@ -92,35 +94,22 @@ covered_files:
- "modules/phrasebook/translator.go"
- "modules/surf/router.go"
- "scripts/check-phase9.sh"
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."
covered_digest: "v2:sha256:e044b747d6e6be8c322be33286dc912e1d99b9fad727b0227877682f75a28ba1"
covered_files_note: "Regenerated 2026-10-11 at summercms.go HEAD be82bcc (code at b45af8e) and fonoteka.go HEAD e074028. Added modules/cabana/api_tokens.go (stampThrottle, on the composite guard's token path) and token_guard_test.go (TestTokenLastUsedThrottlePerGuard). 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. The five ROADMAP success criteria are the contract."
re_verification:
previous_status: passed (stale, covered_changed)
previous_score: 5/5
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: []
previous_status: gaps_found
previous_score: "5/5 roadmap success criteria; plan truths 53/54"
previous_verified: 2026-10-11T13:00:45Z
closed_by: "quick 261011-kzi: fonoteka.go e074028 (test-only inventory update), summercms.go b45af8e (per-guard stampThrottle)"
gaps_closed:
- "09-12: Phase 9 fail-closed acceptance gate green. fonoteka phase09AdminRoutes now lists the 10 post-phase routes as protected; TestPhase09SecurityRoutes and TestPhase09SecurityMatrix PASS; scripts/check-phase9.sh --security and --all exit 0 (re-run by the verifier)."
advisory_closed:
- "TestBackendTokenPostgres order dependence: the process-global lastStamp map is gone (grep: no hits in modules/cabana). The 11-test run from the previous report plus TestTokenLastUsedDebounce and TestTokenLastUsedThrottlePerGuard gives 13/13 PASS on PostgreSQL, and the full Docker-backed go test -count=1 ./modules/cabana is ok (84.8s)."
gaps_remaining: []
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)$'"
regressions: []
human_verified:
- test: "Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422)"
uat_row: 1
@@ -136,27 +125,44 @@ human_verified:
# 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-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.
**Verified:** 2026-10-11T13:38:34Z (summercms.go HEAD be82bcc, code at b45af8e; fonoteka.go HEAD e074028)
**Status:** passed
**Re-verification:** Yes, after gap closure. The 13:00Z run today found `gaps_found` (stale fonoteka route inventory). Quick task 261011-kzi closed it. This run re-checked the gap and the code changes from that task, and ran regression checks on everything else.
**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.
## Summary of this re-verification
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.
The single blocker is closed, and I checked that by running the evidence, not by reading the quick-task SUMMARY:
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`.
- `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1` (fonoteka.go): `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS.
- `scripts/check-phase9.sh --security`: "phase9 security passed", exit 0.
- `scripts/check-phase9.sh --all`: self-test, security, postgres, openapi and evidence all pass, exit 0 (log below).
- Assembled Phase 9 suites in fonoteka.go: 79 PASS, 0 FAIL, 0 SKIP (previous run: 78 PASS, 1 FAIL).
### What the later edits changed on the Phase 9 path (read in code, not taken from SUMMARYs)
**Gap fix (fonoteka.go e074028).** The commit adds exactly 16 lines to `phase09AdminRoutes`: the 10 routes, each `public: false`, plus comments. The assertions in `TestPhase09SecurityRoutes` are unchanged. The test still fails on a missing route, a non-raw route, a protected route without `backend` middleware, a public route with it, and any unlisted admin route. I checked each of the 10 routes in `modules/cabana/http.go` `service.mount`. All are registered inside `r.GroupRaw(api, []string{"backend"}, …)`, which opens at line 264 and closes at line 387:
- **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.
| Route | http.go line | Extra gate |
|---|---|---|
| `PUT /account` | 268 | `interactiveOnly`, `requireAjax` |
| `POST /account/avatar` | 269 | `interactiveOnly`, `requireAjax` |
| `DELETE /account/avatar` | 270 | `interactiveOnly`, `requireAjax` |
| `GET /account/tokens` | 271 | `interactiveOnly` |
| `POST /account/tokens` | 272 | `interactiveOnly`, `requireAjax` |
| `GET /account/tokens/scopes` | 273 | `interactiveOnly` |
| `DELETE /account/tokens/{id}` | 274 | `interactiveOnly`, `requireAjax` |
| `GET /field-types` | 278 | `tokenRouteGate` (handler) |
| `POST /markdown/preview` | 280 | `requireAjax`, `tokenRouteGate` (handler) |
| `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | 386 | `requireAjax`; handler calls `protect` then `operationDeclared` (`field_type_upload.go:33,44`) |
None of the 10 routes is in the public `/auth`, `/lang` or `adminPrefix` groups.
**Throttle change (summercms.go b45af8e) does not weaken any Phase 9 must-have.** Since a3b502b (the HEAD of the previous report), the only code changes are `api_tokens.go`, `token_guard.go`, `http.go` and two test files (`git diff --stat a3b502b HEAD`).
- `compositeGuard.AuthenticateCredential` (`token_guard.go:62-75`) is unchanged: a Bearer value with the `g15_` prefix goes to `authenticateToken`, and everything else goes to `g.jwt.Authenticate`. `g.jwt` is still `bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)` (`http.go:151,156`). The cookie-less resolver also uses `NewBackendJWTGuard` (`http.go:172-173`).
- The only change on the token path is the debounce of the `last_used_at` write. It now happens after all authentication and scope decisions (`token_guard.go:120`) and does not affect the result. Token lookup, `subtle.ConstantTimeCompare`, `users.FindByID` (backend users only), `methodFilterScopes`, and `principal.Scope = limit` are unchanged.
- Scope narrowing is untouched. `token_scopes.go` (`ownerHas`, line 237; `allowsUnderScope`, line 246) and `contracts.go` (`Allows`) have no diff. `TestAllowsScope`, `TestScopeCoversAll`, `TestScopeMatch`, `TestTokenRouteGates` and `TestInteractiveOnly` PASS.
- Guard ownership (`guards.Owner("backend")`, `http.go:163-169`) is unchanged.
- A nil `*stampThrottle` returns `due == true`, so a zero-value guard still stamps (the SQL UPDATE stays conditional) and cannot panic. The race run is clean.
## User Flow Coverage
@@ -164,12 +170,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` (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 |
| 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 on PostgreSQL (re-run today) | VERIFIED |
| Log in separately | Backend login returns a `backend`-audience JWT; frontend credentials fail; ambiguous identifiers fail opaquely | `auth.go` `findBackendLogin`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier`, `TestPhase09GuardIsolation` (via `check-phase9.sh --security`), 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`; Fonoteka `TestAdminMetadata*`: PASS | VERIFIED |
| Open a controller | 403 without the controller permission, before any schema or SQL work | `http.go` `protect`, `operationDeclared`; `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`, run twice inside `--all`): PASS | VERIFIED |
| Outcome: permission-gated, reusable, decoupled | No app names in the framework; admin identity never touches frontend users | No new non-test framework code beyond the throttle; token principals still resolve only through `BackendUsers.FindByID` | VERIFIED |
## Goal Achievement
@@ -177,82 +183,83 @@ 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 `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. |
| 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`; `TestBackendAdmin*` PASS on PostgreSQL today). Separate `backend` guard with its own secret and required audience, wrapped by the composite guard, which still hands every non-`g15_` request to `bouncer.NewBackendJWTGuard` (re-read after b45af8e). 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 in `--security`). A foreign `backend` guard still fails boot (ownership check unchanged). Grants = role + user overlay (`TestBackendUserPermissionsOverrideRole` PASS). Every controller route, including field upload, goes through `protect`. Personal API tokens narrow and never widen (`TestAllowsScope`, `TestBackendTokenGuard`, `TestTokenRouteGates`, `TestInteractiveOnly`, `TestResolveBackend`, `TestTokenTracerPostgres`, `TestBackendTokenPostgres`: 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 | No change since the 13:00Z run (`form_schema.go` not in the a3b502b..HEAD diff). `TestCRUDRequiredFollowsContext` PASS today; full `./modules/cabana` package ok; Fonoteka `TestAlbumsAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`: PASS. |
| 3 | `columns.yaml` parses into a JSON list schema with searchable/sortable/relation columns and datetime/switch renderers. | ✓ VERIFIED | No change since the 13:00Z run. `TestListSearchNonTextColumns` (PostgreSQL) PASS today; 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 | No change since the 13:00Z run. Fonoteka `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial`, `TestRelationCandidateExclusions`: PASS; full `./modules/cabana` package ok. |
| 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 | No change since the 13:00Z run. `TestBulkDelete*`, `TestCRUDHooks`, `TestHooksReceiveTheWriteTransaction`, `TestCRUDFillTypeIsValidation`: PASS on PostgreSQL today (27-test set). Fonoteka `TestAdminSettings*`, `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. 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`.
There are 54 plan truths across 09-01 to 09-12. Fonoteka's named Phase 9 suites at e074028 (`-run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*|TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled|TestRelationCandidateExclusions)$'`) gave 79 PASS, 0 FAIL, 0 SKIP.
| 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. |
| 09-12 #2: complete threat matrix executable, enforced by the fail-closed Phase 9 gate | ✓ VERIFIED (gap closed) | `check-phase9.sh --security` exit 0, `--all` exit 0. `TestPhase09SecurityRoutes` PASS with assertions unchanged; all 10 added routes carry `backend` middleware (checked in http.go, table above). |
| All other 53 plan truths | ✓ VERIFIED | Named tests above and in the Spot-Checks table pass; `check-phase9.sh --all` (self-test, security, postgres, openapi, evidence) exit 0. |
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`.
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`. `check-phase9.sh --openapi` passes inside `--all`.
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`, `TestLoginAmbiguousIdentifier`, `TestMissingUserHashUsesConfiguredCost` PASS; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) PASS. `lower(email)` unique index tests PASS | VERIFIED |
| 09-01: login accepts login or normalized email, one opaque failure, never falls back to a frontend user | `TestAdminAuthLifecycle`, `TestLoginAmbiguousIdentifier` PASS today; Fonoteka `TestAdminTracerGenreList` (frontend user with the admin's email rejected) 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 |
| 09-12: AUTH-08 is the separate D-01/D-02 principal only; no shared role table or dual-purpose token | Separate tables. Personal API tokens live in `backend_api_tokens`, resolve only through `BackendUsers.FindByID`, and the non-`g15_` path is the backend JWT guard. `TestPhase09GuardIsolation`, `TestBackendTokenGuard`, `TestBackendTokenPostgres` PASS | VERIFIED |
### Prohibitions (judgment tier, non-authoritative verdicts)
The 24 verdicts accepted in UAT row 3 still stand at HEAD. Re-checked against the later edits:
The 24 verdicts accepted in UAT row 3 still stand. b45af8e changes only when `last_used_at` is written, and e074028 is test-only, so neither touches a prohibition:
| Plan | Prohibition | Verdict at HEAD |
|---|---|---|
| 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) |
| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting`/`operationDeclared` on every controller and settings route, including field upload) |
| 02 | Auth logs carry no password, JWT, secret or hash | not violated (the throttle's failure log carries only `token_id` and the error; `logTokenAuth` unchanged) |
| 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 |
| 03 | Unknown keys, types or providers not silently ignored | not violated |
| 11 | Navigation/settings metadata must not reveal inaccessible entries | not violated |
| 12 | Acceptance must not depend on skipped PostgreSQL tests or zero-test matches | not violated (`--all` self-test refuses planted skip/zero-test cases; real stages green) |
| others | (see 2026-10-05 report) | unchanged |
### Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `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/lagoon/backend_admin_migrations.go` | backend users/roles, seed, case-insensitive email, metadata + api tokens | ✓ VERIFIED | `TestBackendAdmin*` PASS |
| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me, user overlay | ✓ VERIFIED | unchanged since the 13:00Z run |
| `modules/cabana/token_guard.go`, `token_scopes.go`, `api_tokens.go` | composite guard (16.3) on the Phase 9 gate path | ✓ VERIFIED | Routing and narrow-only scope unchanged; throttle now per guard (b45af8e); 13 token tests PASS together |
| `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/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) |
| `modules/cabana/http.go` | route mounting, `protect`, `operationDeclared`, `relationMutation` | ✓ VERIFIED | b45af8e adds one line (`resolverGuard.stamps = guard.stamps`) |
| `modules/cabana/form_schema.go`, `schema_types.go`, `list_schema.go`, `filter_schema.go`, `query.go`, `model_fields.go` | strict compilers, allowlisted query | ✓ VERIFIED | unchanged |
| `modules/cabana/crud.go`, `tx_context.go`, `modules/lagoon/fill.go` | CRUD, hooks, bulk delete, tx on context | ✓ VERIFIED | unchanged |
| `modules/cabana/relation.go`, `relation_field.go` | relation schema and link/unlink | ✓ VERIFIED | unchanged |
| `modules/cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED | unchanged |
| `modules/bouncer/jwt.go`, `refresh.go`, `registry.go` | audience guard, refresh-window verification, ownership | ✓ VERIFIED | unchanged since 104a1c9 |
| `modules/cabana/security_coverage_test.go` | framework route inventory | ✓ VERIFIED | `TestPhase09SecurityCoverage` PASS |
| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED | `--security` exit 0; `--all` exit 0 |
| `fonoteka.go/.../admin_phase09_security_test.go` | assembled route inventory | ✓ VERIFIED | e074028 lists the 10 routes; `TestPhase09SecurityRoutes` PASS |
| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS (twice, inside `--all`) |
| `fonoteka.go/.../controllers/*_admin_controller.go`, model YAML, `admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | five controllers and registries | ✓ VERIFIED | fonoteka diff 7fc790d..e074028 touches only the inventory test |
### Key Link Verification
| From | To | Via | Status |
|---|---|---|---|
| `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 |
| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` | WIRED |
| `cabana` `backend` guard | `modules/bouncer/jwt.go` | `newCompositeGuard(NewBackendJWTGuard(secret, …))` (`http.go:151,156`); ownership via `Registry.Owner` | WIRED |
| composite guard token path | `backend_users` | `users.FindByID(row.BackendUserID)` (`token_guard.go:101`) | WIRED |
| composite guard token path | `backend_api_tokens.last_used_at` | `stampLastUsed(ctx, g.stamps, …)`; backend and resolver guards share one `stampThrottle` (`http.go:175`) | WIRED |
| `modules/cabana/http.go` logout | `modules/bouncer/refresh.go` | `VerifyRefreshableClaimsAudience` | 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 |
| `settings.go` | form pipeline | `Localize`, `Fill`, `Validate` | WIRED |
| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED |
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (assembled stage red) |
| `scripts/check-phase9.sh` | framework + assembled security tests | named stages, zero-test detection | WIRED (all stages green) |
### Data-Flow Trace (Level 4)
@@ -265,13 +272,17 @@ The 24 verdicts accepted in UAT row 3 still stand at HEAD. Re-checked against th
### Behavioral Spot-Checks
All run by the verifier on 2026-10-11 at summercms.go b45af8e / fonoteka.go e074028.
| Behavior | Command | Result | Status |
|---|---|---|---|
| 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 |
| Framework vet + short suite | `go vet ./...`; `go test -short -count=1 ./...` | vet clean; 38 packages ok, none failing | ✓ PASS |
| Token guard under the race detector | `go test -short -count=1 -race ./modules/cabana -run 'Token\|ResolveBackend\|Phase09\|Allows'` | ok | ✓ PASS |
| 16.3 guard on the Phase 9 path (previously order-dependent) | `go test -count=1 ./modules/cabana -run '^(TestAllowsScope\|TestBackendTokenGuard\|TestBackendTokenPostgres\|TestInteractiveOnly\|TestResolveBackend\|TestScopeCoversAll\|TestScopeMatch\|TestTokenRouteGates\|TestTokenTracerPostgres\|TestTokenGatesSmoke\|TestAccountTokens\|TestTokenLastUsedThrottlePerGuard\|TestTokenLastUsedDebounce)$'` | 13 PASS; `TestBackendTokenPostgres` PASS after `TestAccountTokens` and `TestTokenTracerPostgres` | ✓ PASS |
| Full cabana package (Docker) | `go test -count=1 ./modules/cabana` | ok (84.8s) | ✓ 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 |
| Assembled route inventory + denial matrix | (fonoteka.go) `go test ./plugins/golem15/fonoteka -run '^TestPhase09Security' -count=1 -v` | `TestPhase09SecurityRoutes` PASS, `TestPhase09SecurityMatrix` PASS | ✓ PASS |
| Assembled Phase 9 suites | (fonoteka.go) named suites (above) | 79 PASS, 0 FAIL, 0 SKIP | ✓ PASS |
### Probe Execution
@@ -279,19 +290,16 @@ No `scripts/*/tests/probe-*.sh` exists and no plan declares one. The phase gate
| Probe | Command | Result | Status |
|---|---|---|---|
| `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-admin-openapi.sh` | `--check` | exit 0 | PASS |
| `scripts/check-phase9.sh` | `--security` | "phase9 security passed", exit 0 | PASS |
| `scripts/check-phase9.sh` | `--all` | self-test refuses its 4 planted cases then "phase9 self-test passed"; "phase9 security passed"; "phase9 postgres passed"; "phase9 openapi passed"; "phase9 evidence files passed"; security/postgres/openapi again; "phase9 evidence passed"; exit 0 | PASS |
`--evidence` and `--all` call `run_security` and are therefore red as well; they were not run separately.
After `--all`, fonoteka.go's working tree showed only its three pre-existing submodule pointer changes. The openapi stage left no diff.
### 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 (behavior); acceptance inventory red | Truth 1; gap below |
| 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; acceptance gate green |
| 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 |
@@ -304,33 +312,36 @@ REQUIREMENTS.md maps exactly these six IDs to Phase 9; no orphaned requirements.
| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
| `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) |
| `modules/cabana/{account_tokens,account_tokens_test,token_postgres_smoke_test,token_routes_test,token_scopes_test}.go` | — | not gofmt-formatted (predates b45af8e, per the quick SUMMARY) | ℹ️ Info (16.3 scope) | Cosmetic. Not in the Phase 9 covered set; I did not re-check it |
No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits).
The previous blocker (stale `admin_phase09_security_test.go` inventory) and the info item (`lastStamp` process-global map) are resolved. The five files touched by b45af8e are gofmt-clean, and they and the fonoteka inventory test have no `TBD`/`FIXME`/`XXX`.
### Advisory (New Scope, Unevidenced)
None. The previous advisory (`TestBackendTokenPostgres` order dependence) is closed, see `re_verification.advisory_closed`.
### Human Verification Required
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:
None. All three 09-UAT.md rows (recorded 2026-10-01T21:13:02Z) were decisions and triage, not runtime observations. The code changed since this morning's report is the token `last_used_at` debounce and one fonoteka test inventory, and neither touches what those decisions rest on:
| UAT row | Rests on | Still holds at HEAD |
|---|---|---|
| 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 |
| 1 CR-01 | Fill type errors answer 422 | `crud.go` unchanged; `TestCRUDFillTypeIsValidation` PASS today |
| 2 WR-01..WR-19 triage (WR-03, WR-11, WR-17 decisions) | `operationDeclared` delete rule; `lower(email)` index; `applyUserPermissions` exact-code merge | `TestCRUDOperationsFollowDeclarations`, `TestBackendAdmin*`, `TestBackendUserPermissionsOverrideRole` PASS today; `auth.go`, `contracts.go` unchanged |
| 3 24 prohibitions | see Prohibitions table | none newly violated |
They are recorded under `human_verified` and are not re-requested.
### Gaps Summary
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.
None remaining. The one blocker from the 13:00Z run, the stale app-side route inventory, was fixed by a test-only fonoteka.go commit (e074028). The verifier re-ran it and it is green. Each of the 10 routes is confirmed inside cabana's `backend` group, and the field upload route also goes through `protect`/`operationDeclared`. `check-phase9.sh --security` and `--all` exit 0.
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.
The 16.3 advisory is also resolved. b45af8e replaces the process-global `lastStamp` map with a per-guard `stampThrottle`. It changes only when `last_used_at` is written, after authentication and scope decisions. The `g15_`/JWT routing, the backend JWT guard, guard ownership and narrow-only scopes are unchanged, and their tests pass, including on PostgreSQL and under `-race`.
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.
A process note carries over from the last report, still not acted on: framework commits that add an admin route should also update fonoteka's `phase09AdminRoutes`, or run `check-phase9.sh --security` first. The root `go test ./...` does not run fonoteka.go tests.
---
_Verified: 2026-10-11T13:00:45Z_
_Verified: 2026-10-11T13:38:34Z_
_Verifier: Claude (msd-verifier)_