From 7ac75b918d2c0fe5bc9f35be79dec877d9e2e0fc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 28 Sep 2026 00:03:21 +0200 Subject: [PATCH] test(09): persist human verification items as UAT --- .../09-UAT.md | 40 +++ .../09-VERIFICATION.md | 294 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-UAT.md create mode 100644 .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md diff --git a/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-UAT.md b/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-UAT.md new file mode 100644 index 0000000..121a877 --- /dev/null +++ b/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-UAT.md @@ -0,0 +1,40 @@ +--- +status: testing +phase: 09-backend-admin-authentication-and-schema-pipeline +source: [09-VERIFICATION.md] +started: 2026-09-27T22:03:21Z +updated: 2026-09-27T22:03:21Z +--- + +## Current Test + +number: 1 +name: Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422) +expected: | + Either fix it before closing Phase 9 (normalize json.Number before lagoon.Fill or teach convertValue about it, map conversion failures to a per-field 422, add a CRUD test writing a non-protected number field bound to an int column), or record an explicit deferral or override with a reason. +awaiting: user response + +## Tests + +### 1. Decide CR-01 (admin CRUD cannot save a writable number field; type mismatches return 500 instead of 422) +expected: Fixed before closing Phase 9, or an explicit deferral/override with a reason is recorded. +result: [pending] + +### 2. Triage the 19 open code-review warnings (priority: WR-01, WR-02, WR-14, WR-17 for AUTH-08 Winter permission semantics) +expected: Each warning set to fixed, deferred (with reason) or skipped in 09-REVIEW-DISPOSITION.md; none left open. +result: [pending] + +### 3. Review the 24 judgment-tier prohibitions in 09-VERIFICATION.md +expected: Verdicts accepted or rejected; the two qualified ones (09-11 #1 navigation reveals albums target to a genres-only admin, WR-02; 09-12 #1 zero-test detection per invocation, not per package, WR-18) are resolved. +result: [pending] + +## Summary + +total: 3 +passed: 0 +issues: 0 +pending: 3 +skipped: 0 +blocked: 0 + +## Gaps diff --git a/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md b/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md new file mode 100644 index 0000000..9593666 --- /dev/null +++ b/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VERIFICATION.md @@ -0,0 +1,294 @@ +--- +phase: 09-backend-admin-authentication-and-schema-pipeline +verified: 2026-09-28T00:10:00Z +status: human_needed +score: 5/5 roadmap success criteria verified (plan truths 54/54 verified, including 3 backstop truths with direct test evidence) +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" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-02-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-02-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-03-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-03-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-04-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-04-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-05-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-05-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-06-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-06-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-07-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-07-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-08-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-08-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-09-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-09-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-10-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-10-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-11-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-11-SUMMARY.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-12-PLAN.md" + - ".planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-12-SUMMARY.md" + - "bouncer/audience_test.go" + - "bouncer/backend_guard_test.go" + - "bouncer/context.go" + - "bouncer/jwt.go" + - "bouncer/mint.go" + - "bouncer/refresh.go" + - "cabana/admin_openapi.go" + - "cabana/auth.go" + - "cabana/auth_test.go" + - "cabana/bulk_test.go" + - "cabana/commands.go" + - "cabana/commands_test.go" + - "cabana/contracts.go" + - "cabana/crud.go" + - "cabana/crud_lifecycle_test.go" + - "cabana/crud_test.go" + - "cabana/filter_schema.go" + - "cabana/form_schema.go" + - "cabana/form_schema_test.go" + - "cabana/http.go" + - "cabana/list_schema.go" + - "cabana/list_schema_test.go" + - "cabana/metadata_settings_test.go" + - "cabana/navigation.go" + - "cabana/phase09_contract_test.go" + - "cabana/query.go" + - "cabana/query_test.go" + - "cabana/registry.go" + - "cabana/relation.go" + - "cabana/relation_test.go" + - "cabana/schema.go" + - "cabana/schema_types.go" + - "cabana/security_coverage_test.go" + - "cabana/security_test.go" + - "cabana/settings.go" + - "internal/build/artifact.go" + - "internal/build/build.go" + - "internal/build/build_test.go" + - "internal/build/stubs/artifacts.tmpl" + - "lagoon/backend_admin_migrations.go" + - "lagoon/backend_admin_migrations_test.go" + - "lagoon/fill.go" + - "lagoon/migrations.go" + - "pact/capabilities.go" + - "phrasebook/translator.go" + - "scripts/check-phase9.sh" + - "surf/router.go" +covered_digest: "v2:sha256:510b91f54dea0918569d267a7aba22fcf879ad69899aa6e2033677a68a84de41" +covered_files_note: "fonoteka.go files are outside the project root and cannot be fingerprinted. They are listed under Required Artifacts and were checked at fonoteka.go HEAD 21c0f12 (clean working tree). summercms.go was checked at HEAD 2ad19bd." +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 (user-story.validate: true), used for User Flow Coverage. As in the Phase 1/3/5/8/10 reports, the five ROADMAP success criteria are the contract." +human_verification: + - test: "Decide CR-01: generic admin CRUD cannot save a writable `type: number` field (json.Number is not convertible by lagoon.Fill), and type mismatches return 500 instead of 422" + expected: "Either fix it before closing Phase 9 (normalize json.Number before Fill or teach lagoon.convertValue about it, map conversion failures to a per-field 422, add a CRUD test that writes a non-protected number field bound to an int column), or record an explicit deferral or override with a reason" + why_human: "Reproduced by the verifier, but it defeats no ROADMAP success criterion: none of the five Płytarium forms or the settings form writes a numeric column. It does defeat the D-06 in-scope `number` field type on writes, which is a scope decision the verifier cannot make." + - test: "Triage the 19 open code-review warnings, in particular the four that touch AUTH-08 semantics: WR-01 (wildcard requirements never match, ALL instead of Winter's ANY), WR-02 (denied navigation parent emitted with its albums target), WR-17 (user-level backend_users.permissions ignored, so Winter denies are lost at cutover), WR-14 (logout cannot revoke an expired-but-refreshable token)" + expected: "Each warning set to fixed, deferred (with reason) or skipped in 09-REVIEW-DISPOSITION.md. All 32 findings are currently `open`." + why_human: "None defeats a success criterion for the current Płytarium configuration (controllers declare single exact codes; the navigation leak matches Winter's own wildcard behavior), but D-01/D-03 promise Winter permission semantics at cutover, and that is a policy call." + - test: "Review the 24 judgment-tier prohibitions (verdicts in the Prohibitions table)" + expected: "Accept or reject the verifier's non-authoritative verdicts. Two are qualified: 09-11 #1 (navigation must not reveal the target of an inaccessible entry; WR-02 shows the parent's albums target to a genres-only admin) and 09-12 #1 (zero-test detection is per invocation, not per package; WR-18)." + why_human: "Judgment-tier prohibitions need human resolution" +--- + +# 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-09-28T00:10:00Z (summercms.go HEAD 2ad19bd, fonoteka.go HEAD 21c0f12) +**Status:** human_needed +**Re-verification:** No. This is the first verification of Phase 9. Phase 10 was already built and verified on top of it, so the checks below run against HEAD. + +**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. + +## User Flow Coverage + +User story (from every 09-*-PLAN.md): *As a backend administrator, I want to authenticate separately and manage resources described by Winter-shaped schemas, so that the administration surface stays permission-gated and reusable without coupling it to frontend users.* + +| Step | Expected | Evidence | Status | +|---|---|---|---| +| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `cabana/commands.go`, `RuntimeCommands` in generated `main.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand` (full suite PASS) | VERIFIED | +| Log in separately | Backend login by login or email returns a `backend`-audience JWT signed with `admin.jwt.secret`; frontend credentials fail | `cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (a frontend user with the same email is rejected), `TestPhase09GuardIsolation` (re-run: PASS) | VERIFIED | +| See permitted navigation | `/navigation` lists only permitted items | `cabana/navigation.go` `Metadata`; `TestAdminMetadataFiltering` (developer sees fonoteka, publisher sees `[]`), re-run: PASS | VERIFIED (WR-02 caveat) | +| Open a controller | 403 without the controller permission, before any schema or SQL work | `cabana/http.go` `protect`; `TestPhase09PermissionMatrix`, `TestAdminTracerPermissionBoundary`, `TestPhase09SecurityRoutes` (re-run: 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` (re-run: PASS, real PostgreSQL) | VERIFIED | +| Outcome: permission-gated, reusable, decoupled | Framework has no app names; admin identity never touches frontend users | `grep -i "fonoteka\|collection_editors\|granted_by\|golem15_"` on non-test `cabana/*.go`: no hits. `BackendUsers.FindByID` reads only `backend_users` | VERIFIED | + +## Goal Achievement + +### Observable Truths (ROADMAP contract) + +| # | 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` tables (`lagoon/backend_admin_migrations.go`, developer/publisher seeded as system roles). Separate `backend` guard with its own secret and a required `backend` audience. Cross-audience tokens fail in both directions (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`). Grants come from the role JSON plus `HasPermissions` role assignments (`cabana/auth.go:36-61`). Every controller, relation and CRUD route goes through `protect`: guard, then controller lookup, then `Allows(principal, RequiredPermissions())`, then work (`cabana/http.go:701-719`). Settings go through `protectSetting`. `/navigation` and `/settings` filter entries by the same `Allows`. The five Fonoteka controllers each declare one exact code (`access_albums` etc.). Tests re-run and passing: `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`. **Caveats (warnings, not failures):** WR-01 (a wildcard *requirement* never matches a specific grant, and multi-code requirements use ALL where Winter uses ANY; no Fonoteka controller or setting uses either), WR-02 (a genres-only admin gets the `fonoteka` parent entry with `controller: golem15.fonoteka.albums`, which then answers 403; Winter's `hasAnyAccess` wildcard shows the same parent), WR-17 (user-level `backend_users.permissions` is ignored). | +| 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 | `cabana/form_schema.go` decodes with `github.com/goccy/go-yaml` and `yaml.DisallowUnknownField()` (line 279), walks the AST to keep order, and rejects unknown keys and types and `type: partial` (line 344). The real Fonoteka YAML covers every listed item: text (all models), textarea (genre, collection), checkbox (artist `is_various`), switch (settings), dropdown `options: getFormatOptions` (album, served by `Album.DropdownOptions`), relation with `nameFrom` and `emptyOption` (album `genre`, collection `owner`), span (all), tab (collection `editors`), context (style `slug`, collection `editors`), attributes (style `slug` readonly). Tests re-run: `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 | `cabana/list_schema.go` column types `text`, `datetime`, `switch` (line 23); `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`cabana/schema_types.go:20-24`). Real YAML: searchable (all models), sortable (album `format`, collection `created_at`), relation + select (album `genre.name`, collection `owner.username`, mapped to `email` by `ListRelationColumnMapper`), `type: datetime` (collection `created_at`), `type: switch` (artist `is_various`). The list query resolves search/sort/filter only through compiled allowlists, with a primary-key tie-break. Tests re-run: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. Caveat: WR-08 (a non-text searchable column gives a PostgreSQL error and a 500; no Fonoteka searchable column is non-text, but the scaffold emits `id: searchable: true`). | +| 4 | The relation-manager schema supports search/link/unlink/manage-or-view lists for Collections' editors tab, replacing the `partial` field entirely. | ✓ VERIFIED | `models/collection/fields.yaml` `editors: type: relation-manager, relation: editors, tab, span: full, context: update`. There is no `partial` anywhere in the Fonoteka fields YAML, and the compiler rejects it. `controllers/collections/config_relation.yaml` has `view.list.columns`, `manage.list.columns`, `toolbarButtons: link\|unlink`, `showSearch`. `cabana/relation.go` serves schema, linked, candidates (with `RelationExtendManageQuery` applied at line 493), link and unlink (`RelationBeforeLink` at line 683), with explicit pivot writes and no hardcoded pivot names. Tests re-run on PostgreSQL: `TestCollectionsAdminRelationSchema`, `Link`, `Unlink`, `Idempotent`, `ForgedPivot`, `CrossScope`, `Concurrent`, `RelationEdges`, `RejectsPartial`, `TestRelationCandidateExclusions`: PASS. Caveats: WR-05 (link/unlink do not check `toolbarButtons`), WR-07 (column order depends on 16-space indentation; the tracked file uses it), WR-15 (`granted_by` stores a backend admin id in a frontend-user column). | +| 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 | Hooks are optional interfaces in `pact/capabilities.go:193-254`, type-asserted in `cabana/query.go:109`, `cabana/crud.go:439,528,575,591`, `cabana/relation.go:493`. Albums implements the list/form/before-create/before-update hooks (D-14 collection scoping), and Collections implements `RelationExtendManageQuery` and `RelationBeforeLink`. Bulk delete (`CRUDService.BulkDelete`, `cabana/crud.go:187`) normalizes and sorts ids, locks the scoped rows through `ListExtendQuery`, and deletes each row separately with `tx.Delete(model)`, so GORM model hooks and Form*Delete hooks fire per record. It never issues a single `DELETE ... IN`. `TestBulkDeleteDuplicates`, `TestBulkDeleteIdempotent`, `TestBulkDeleteRollback` and `TestCRUDHooks` (re-run: PASS) assert per-record hook ids in ascending order. The Fonoteka setting is registered through `HasSettings` with a compiled `models/settings/fields.yaml`. Schema, GET and PUT use the same `FormSchema.Localize`, writable projection, `lagoon.Fill` and `lagoon.Validate` (`cabana/settings.go`). `TestAdminSettings*` (8 tests, re-run on PostgreSQL: PASS). **CR-01 (confirmed, escalated):** see Human Verification item 1. It does not affect this truth for the delivered app, because `search_use_typesense` is a `bool` switch and no Fonoteka form writes a numeric column. | + +**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 `` block. I re-ran every named Fonoteka suite (`TestAlbumsAdmin*`, `TestArtistsAdmin*`, `TestGenresAdmin*`, `TestStylesAdmin*`, `TestCollectionsAdmin*`, `TestAdminMetadata*`, `TestAdminSettings*`, `TestAdminTracer*`, `TestAdminAuthLifecycleAssembled`, `TestPhase09*`) with `-v`: 79 PASS, 0 SKIP, 0 FAIL. The framework suites (`bouncer`, `cabana`, `lagoon`, `internal/build`) passed in the full `go test ./...` run. + +Truth 09-12 #4 names `../fonoteka.go/docs/openapi.json` as the admin OpenAPI home. Phase 10 D-15 intentionally moved the admin paths to the framework-owned `summercms.go/admin/openapi/admin.json`, and `fonoteka.go/docs/openapi.json` is again the parity document. The intent still holds. `admin.json` lists all 21 D-09 paths, `scripts/check-admin-openapi.sh --check` exits 0, and `scripts/check-phase9.sh --openapi` exits 0. + +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` (login by `life` and by `life@example.test` against `Life@Example.Test`), `TestAdminInactive`/`TestAdminDeleted` (`assertSameOpaque`), `TestAdminTracerGenreList` (inserts a frontend user with the admin's email and asserts the frontend password is rejected). All re-run: PASS | VERIFIED (WR-11 caveat: `login = ? OR lower(email) = ?` takes the first match on a cross-field collision) | +| 09-11: missing settings GET is side-effect-free with `exists: false`; first PUT creates; identical PUT is idempotent | `TestAdminSettingsMissingRead`, `TestAdminSettingsCreate`, `TestAdminSettingsIdempotentUpdate` (re-run: 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 and migration, `Principal.Backend` provenance flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal` (PASS) | VERIFIED | + +### Prohibitions (judgment tier, non-authoritative verdicts) + +| Plan | Prohibition | Verdict | +|---|---|---| +| 01 | Backend identities must not share frontend user rows, the jwt guard, or a signing secret | not violated | +| 01 | Hidden navigation must not replace server-side authorization | not violated (`protect`/`protectSetting` on every route) | +| 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 (Phase 10 carries the same JWT in a cookie; there is no server-side session store) | +| 02 | Auth logs carry no password, JWT, secret or hash | not violated (`TestAdminAuthLogging`, `TestPhase09SecurityCoverage`) | +| 03 | Form compiler must not accept `type: partial` | not violated (`form_schema.go:344`) | +| 03 | Labels not deferred to the client or cached in the first request's locale | not violated (per-request `Localize`; T-09-06) | +| 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`) | +| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break; adjacent-page tests) | +| 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 (the album form has no user choice field) | +| 07 | Artist parity not reached by silently omitting Winter keys | not violated (Phase 10 `TestPhase10Controllers` asserts fields and columns equal the tracked YAML) | +| 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 (grep of non-test `cabana/*.go`: no hits) | +| 10 | Owner, cross-collection or already-linked candidates not linkable by forgery | not violated (`ForgedPivot`, `CrossScope`, `Idempotent`) | +| 11 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **qualified**: a genres-only admin receives the parent `fonoteka` entry with `controller: golem15.fonoteka.albums` (WR-02). Only the parent's default target leaks, and Winter behaves the same way. | +| 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 | **qualified**: `check-phase9.sh --self-test` refuses skips and zero-test runs (re-run: PASS), but detection is per invocation, not per package (WR-18). | +| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md` names a command per threat; six rows are "owned by 09-0x; not re-run in 09-12", and all of those passed in this session) | + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|---|---|---|---| +| `lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed | ✓ VERIFIED | Explicit SQL up/down, no AutoMigrate; `TestBackendAdmin*` in lagoon suite PASS | +| `cabana/auth.go` | backend provider, login/refresh/logout/me | ✓ VERIFIED | Wired from `Activate`; `BackendUsers` reads only `backend_users` | +| `cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | `RuntimeCommands` appended in the generated main | +| `cabana/http.go` | route mounting, `protect` | ✓ VERIFIED | Mounted from `surf.BuildRouter` via `cabana.Activate` | +| `cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | goccy/go-yaml strict decode | +| `cabana/list_schema.go`, `filter_schema.go`, `query.go` | list compiler and allowlisted query | ✓ VERIFIED | | +| `cabana/crud.go` | CRUD, projection, hooks, bulk delete | ✓ VERIFIED (CR-01) | Numeric writes fail inside `lagoon.Fill` | +| `cabana/relation.go` | relation schema and link/unlink | ✓ VERIFIED | | +| `cabana/navigation.go`, `settings.go` | filtered metadata, singleton settings | ✓ VERIFIED (WR-02) | | +| `scripts/check-phase9.sh` | fail-closed gate | ✓ VERIFIED | `--self-test`, `--openapi` re-run: exit 0 | +| `fonoteka.go/.../controllers/{albums,artists,collections,genres,styles}_admin_controller.go` | five controllers with permissions | ✓ VERIFIED | Registered through `HasAdminControllers` | +| `fonoteka.go/.../models/*/fields.yaml`, `columns.yaml`, `controllers/*/config_*.yaml` | Winter-shaped YAML | ✓ VERIFIED | Embedded; `partial` replaced by `relation-manager` | +| `fonoteka.go/.../admin_permissions.go`, `admin_navigation.go`, `admin_settings.go` | registerPermissions/Navigation/Settings ports | ✓ VERIFIED | | +| `fonoteka.go/.../classes/backend_album_collection.go` | D-14 email-to-collection resolver | ✓ VERIFIED | `TestAlbumsAdminCollectionMatch` etc. PASS | +| `fonoteka.go/.../admin_phase09_e2e_test.go` | assembled acceptance | ✓ VERIFIED | `TestPhase09AssembledAcceptance` PASS | + +### Key Link Verification + +| From | To | Via | Status | +|---|---|---|---| +| `surf/router.go` | `cabana/http.go` | `cabana.Activate` in `BuildRouter` | WIRED | +| `cabana/http.go` guard | `bouncer/jwt.go` | `NewBackendJWTGuard(secret, users, bl, ...)` with backend audience | WIRED | +| `cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)` then `cc.List`/`cc.Form`/`cc.Relations` | WIRED | +| `cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction | WIRED (CR-01 breaks numeric values) | +| `cabana/crud.go` bulk | model lifecycle hooks | per-row `tx.Delete(model)` | WIRED | +| `cabana/settings.go` | form pipeline | `setting.Form.Localize`, `Fill`, `Validate` | WIRED | +| `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED | +| `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED | + +### 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 principal grants from `backend_user_roles` | Yes | ✓ FLOWING | +| Settings GET | `data` | `golem15_fonoteka_settings` row or compiled defaults | Yes | ✓ FLOWING | +| Relation linked/candidates | rows | pivot-joined user query with the owner and linked users excluded | Yes | ✓ FLOWING | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|---|---|---|---| +| Framework regression | `go vet ./... && go test ./... -count=1` (summercms.go) | exit 0, 23 packages ok | ✓ PASS | +| App regression | `go vet` + `go test $(go list -f '{{.Dir}}/...' -m) -count=1` (fonoteka.go) | exit 0, 13 packages ok | ✓ PASS | +| Assembled Phase 9 acceptance | `go test ./plugins/golem15/fonoteka -run '^(TestPhase09.*|TestAdminSettings.*|TestAdminMetadata.*|TestCollectionsAdmin.*)$' -v` | 36 PASS, 0 SKIP | ✓ PASS | +| Controllers, auth and tracer | `go test ./plugins/golem15/fonoteka -run '^(TestAlbumsAdmin.*|TestGenresAdmin.*|TestArtistsAdmin.*|TestStylesAdmin.*|TestAdminTracer.*|TestAdminAuthLifecycleAssembled)$' -v` | 43 PASS, 0 SKIP | ✓ PASS | +| Bulk hooks, CRUD hooks, schema compile, permission matrix | `go test ./cabana -run '^(TestBulkDelete(Duplicates|Idempotent|Rollback)|TestCRUDHooks|TestFormSchema(Compile|Rejects)|TestListSchemaCompile|TestRelationCandidateExclusions|TestPhase09PermissionMatrix)$'` | 9 PASS | ✓ PASS | +| Guard isolation | `go test ./bouncer -run '^TestPhase09GuardIsolation$'` | PASS | ✓ PASS | +| CR-01 reproduction | scratch module calling `lagoon.Fill(m, {"year"}, {"year": json.Number("1990")})` on an `int` field | `lagoon: fill year: cannot assign json.Number to int` | ✗ confirms CR-01 | + +### Probe Execution + +No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase gate ran instead: + +| Probe | Command | Result | Status | +|---|---|---|---| +| `scripts/check-phase9.sh` | `--self-test` | "phase9 self-test passed", exit 0 | PASS | +| `scripts/check-phase9.sh` | `--openapi` | "phase9 openapi passed", exit 0 | PASS | +| `scripts/check-admin-openapi.sh` | `--check` | exit 0 | PASS | + +### 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 (warnings WR-01, WR-02, WR-14, WR-17) | +| 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 (CR-01 escalated) | 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. Info: REQUIREMENTS.md still shows all six as `[ ]` / Pending, and the traceability update is left to phase completion. + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|---|---|---|---|---| +| `cabana/crud.go` + `lagoon/fill.go` | 625 / 155-166 | `UseNumber` values passed to `Fill`, which cannot convert `json.Number` | 🛑 (review CR-01; escalated, not a roadmap gap) | Writable number fields 500; type mismatches 500 instead of 422 | +| `cabana/contracts.go` | 105-137 | wildcard requirement unmatched; ALL not ANY | ⚠️ Warning (WR-01) | Latent for future Winter ports | +| `cabana/navigation.go` | 42-54 | denied parent emitted with its target | ⚠️ Warning (WR-02) | Minor metadata disclosure | +| `cabana/auth.go` | 36-76 | user-level permissions ignored | ⚠️ Warning (WR-17) | Winter denies lost at cutover | +| `cabana/query.go`, `internal/build/stubs/artifacts.tmpl` | 294, 139-142 | `LOWER(col)` on non-text columns; scaffold makes `id` searchable | ⚠️ Warning (WR-08) | Scaffolded controllers 500 on search | +| `internal/build/stubs/artifacts.tmpl` | 92-106 | scaffold has no permissions or record source | ⚠️ Warning (WR-09) | Open-to-all admins once completed naively | +| `internal/build/artifact.go` | 179 | `"TODO: describe "` string in generated command stub | ℹ️ Info | Generated text, not a debt marker in phase code | + +The other open review warnings (WR-03 to WR-07, WR-10 to WR-16, WR-18, WR-19) and the 12 info findings are recorded in 09-REVIEW.md. None of them defeats a ROADMAP success criterion for the current configuration. No unreferenced `TBD`/`FIXME`/`XXX` was found in phase files. + +### Human Verification Required + +#### 1. Decide CR-01 (numeric fields cannot be saved) + +**Test:** Choose one: fix before closing the phase, defer with a tracked follow-up, or accept with an override. +**Expected:** If fixed: normalize `json.Number` before `lagoon.Fill` or in `lagoon.convertValue`, map conversion failures to a per-field 422 instead of a `CapabilityError` 500, and add a CRUD test that writes a non-protected `type: number` field bound to an `int` column (plus the settings equivalent). +**Why human:** The verifier reproduced it. It breaks the D-06 in-scope `number` type on writes, which Phase 10's `NumberField.vue` renders, but no ROADMAP criterion and no Płytarium form depends on it. No later phase in the roadmap covers it. + +#### 2. Triage the open review warnings, starting with the AUTH-08 ones + +**Test:** Set dispositions in 09-REVIEW-DISPOSITION.md, where all 32 findings are `open`. +**Expected:** WR-01, WR-02, WR-14 and WR-17 explicitly fixed or deferred with a reason. D-01/D-03 promise Winter permission semantics and a straight `backend_users` copy at cutover (Phase 15). +**Why human:** This is a policy and scope decision. + +#### 3. Review the 24 judgment-tier prohibitions + +**Test:** Accept or reject the verdicts in the Prohibitions table. +**Expected:** Pay particular attention to the two qualified verdicts (09-11 navigation target, 09-12 per-invocation zero-test detection). +**Why human:** Judgment-tier prohibitions need human resolution. + +### 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 `partial`, the CRUD hooks, per-record bulk delete and the settings binding all exist, are wired, and pass their tests on real PostgreSQL at HEAD in both repositories. + +The phase is still not `passed`, for three reasons. The critical review finding CR-01 is real and reproduced: the framework CRUD and settings engine cannot persist numbers, even though `number` is a D-06 in-scope field type. All 19 review warnings are untriaged, and four of them bear on the Winter permission semantics that AUTH-08 and D-03 promise. And 24 judgment-tier prohibitions need human sign-off. None of these blocks Płytarium today, but they are decisions for the developer, not for the verifier. + +--- + +_Verified: 2026-09-28T00:10:00Z_ +_Verifier: Claude (gsd-verifier)_