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 index 9593666..b9172ef 100644 --- 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 @@ -1,6 +1,6 @@ --- phase: 09-backend-admin-authentication-and-schema-pipeline -verified: 2026-09-28T00:10:00Z +verified: 2026-10-01T18:35:29Z 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: @@ -28,91 +28,107 @@ covered_files: - ".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" + - "modules/bouncer/audience_test.go" + - "modules/bouncer/backend_guard_test.go" + - "modules/bouncer/context.go" + - "modules/bouncer/jwt.go" + - "modules/bouncer/mint.go" + - "modules/bouncer/refresh.go" + - "modules/cabana/admin_openapi.go" + - "modules/cabana/auth.go" + - "modules/cabana/auth_test.go" + - "modules/cabana/bulk_test.go" + - "modules/cabana/commands.go" + - "modules/cabana/commands_test.go" + - "modules/cabana/contracts.go" + - "modules/cabana/crud.go" + - "modules/cabana/crud_lifecycle_test.go" + - "modules/cabana/crud_test.go" + - "modules/cabana/filter_schema.go" + - "modules/cabana/form_schema.go" + - "modules/cabana/form_schema_test.go" + - "modules/cabana/http.go" + - "modules/cabana/list_schema.go" + - "modules/cabana/list_schema_test.go" + - "modules/cabana/metadata_settings_test.go" + - "modules/cabana/navigation.go" + - "modules/cabana/phase09_contract_test.go" + - "modules/cabana/query.go" + - "modules/cabana/query_test.go" + - "modules/cabana/registry.go" + - "modules/cabana/relation.go" + - "modules/cabana/relation_test.go" + - "modules/cabana/schema.go" + - "modules/cabana/schema_types.go" + - "modules/cabana/security_coverage_test.go" + - "modules/cabana/security_test.go" + - "modules/cabana/settings.go" + - "modules/cabana/testdata/list/all_columns.yaml" + - "modules/cabana/testdata/list/all_filters.yaml" + - "modules/lagoon/backend_admin_migrations.go" + - "modules/lagoon/backend_admin_migrations_test.go" + - "modules/lagoon/fill.go" + - "modules/lagoon/fill_test.go" + - "modules/lagoon/migrations.go" + - "modules/pact/capabilities.go" + - "modules/phrasebook/translator.go" + - "modules/surf/router.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." +covered_digest: "v2:sha256:642c5cedcadd932448c7a273b70474c751f6d9bf84030c331b63b101427d813d" +covered_files_note: "Paths are current root-relative paths after Phase 10.2 (commit 5e50b16) moved the framework packages under modules/. 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 2da9482 (clean working tree). summercms.go was checked at HEAD 6a2ee9d." 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." +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." +re_verification: + previous_status: human_needed + previous_score: 5/5 + reason: "Previous report (2026-09-28T00:10Z) went stale: Phase 10.2 moved 42 covered framework files under modules/, so its covered_files no longer existed." + gaps_closed: [] + gaps_remaining: [] + regressions: [] + human_items_resolved: + - "CR-01 (numeric writes / 500 instead of 422): fixed in code after Phase 9 by Phase 10.1 commits c3efbc3 and e60e697; TestCRUDFillTypeIsValidation, TestFillJSONNumber and TestFillTypeErrorNamesTheKey pass. The ledger entry in 09-REVIEW-DISPOSITION.md is still `open` and is folded into human item 1." 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`." + - test: "Triage the open code-review findings in 09-REVIEW-DISPOSITION.md (all 32 still `open`). Record CR-01 as fixed (Phase 10.1, c3efbc3 + e60e697), then decide the 19 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 finding set to fixed, deferred (with reason) or skipped; none left `open`. The WR-01/WR-02/WR-17 code paths are unchanged at HEAD (modules/cabana/contracts.go Allows, navigation.go Metadata, auth.go FindByID)." 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)." + expected: "Accept or reject the verifier's non-authoritative verdicts. Three need attention: 09-03 #1 (form compiler must not accept `type: partial`), which Phase 10.1 D-09 deliberately superseded with a bare-name, sanitized html/template partial; 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) +**Verified:** 2026-10-01T18:35:29Z (summercms.go HEAD 6a2ee9d, fonoteka.go HEAD 2da9482) **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. +**Re-verification:** Yes. The 2026-09-28T00:10Z report went stale after Phase 10.2 (commit 5e50b16) moved every framework package under `modules/`. Every truth was re-checked against the code under `modules/` at HEAD, including later changes from Phases 10.1, 11 and the quick tasks. The covered-file list was rebuilt from the Phase 9 commits at current paths, not copied. **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:** + +- `5e50b16` (10.2-01): `bouncer`, `cabana`, `lagoon`, `pact`, `phrasebook`, `surf` moved to `modules/...`. Pure relocation; `57e7b56` retargeted test fixtures. +- `c3efbc3` (10.1-03) and `e60e697` (10.1 CR-01): `lagoon.Fill` converts `json.Number` into integer, unsigned and float fields and returns `*lagoon.FillTypeError`; the cabana save path maps that to a per-field 422. **This resolves the Phase 9 CR-01 defect.** +- `771d2cc`, `9df9fae` (10.1-01/02), Phase 10.1 D-09: form `type: partial` is accepted again, but only with a bare-name `path` rendered through an allowlisted html/template node renderer. The legacy Winter partial (no path) still fails boot. See truth 4 and the 09-03 prohibition. +- `f7b6b0c` (11-08): cabana writes made commit-safe (`crud.go`, `relation.go`); `037dc53`: per-query collation in lagoon. Neither changes a Phase 9 contract; all Phase 9 tests still pass. + ## 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 | +| Provision an admin | `summer admin:create` creates a bcrypt admin with a role; no boot or web seed | `modules/cabana/commands.go`, `RuntimeCommands` in the generated `main.go`; `TestAdminCreateCommand`, `TestAdminResetPasswordCommand` re-run: PASS | VERIFIED | +| Log in separately | Backend login by login or email returns a `backend`-audience JWT signed with `admin.jwt.secret`; frontend credentials fail | `modules/cabana/auth.go`, `bouncer.NewBackendJWTGuard`; `TestAdminAuthLifecycle`, `TestAdminTracerGenreList` (frontend user with the same email rejected), `TestPhase09GuardIsolation` re-run: PASS | VERIFIED | +| See permitted navigation | `/navigation` lists only permitted items | `modules/cabana/navigation.go` `Metadata` (line 36); `TestAdminMetadataFiltering` re-run: PASS | VERIFIED (WR-02 caveat) | +| Open a controller | 403 without the controller permission, before any schema or SQL work | `modules/cabana/http.go` `protect` (line 742): controller lookup, backend principal, `Allows`, then work; `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 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 | ## Goal Achievement @@ -120,27 +136,27 @@ 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` 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. | +| 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 (`modules/lagoon/backend_admin_migrations.go`; `TestBackendAdminMigration`, `Seed`, `Rollback`, `WinterRow` re-run: PASS). Separate `backend` guard with its own secret and a required `backend` audience; cross-audience tokens fail both ways (`TestPhase09GuardIsolation`, `TestAdminTracerAuthBoundary`: PASS). Grants come from the role JSON plus `HasPermissions` role assignments (`modules/cabana/auth.go` `FindByID`/`principalFrom`). Every controller, relation and CRUD route goes through `protect` (`modules/cabana/http.go:742`); settings through `protectSetting` (line 375). `/navigation` and `/settings` filter entries with the same `Allows` (`contracts.go:127`). Tests re-run: `TestPhase09PermissionMatrix`, `TestPhase09SecurityMatrix`, `TestAdminMetadataFiltering`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS. **Caveats (warnings, unchanged at HEAD):** WR-01 (`granted` matches grant wildcards only; a wildcard *requirement* never matches, and `Allows` requires ALL codes where Winter uses ANY), WR-02 (`Metadata` keeps a denied parent when a child is allowed and emits it with its own target), WR-17 (user-level `backend_users.permissions` 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 | `modules/cabana/form_schema.go` imports `github.com/goccy/go-yaml` (+ `ast`), decodes with `yaml.DisallowUnknownField()` (line 286), walks the AST to keep order, rejects unknown keys and types. The real Fonoteka YAML covers every listed item (text, textarea, checkbox `is_various`, switch in settings, dropdown `options: getFormatOptions`, relation with `nameFrom`/`emptyOption`, span, tab, context, attributes). 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 | `modules/cabana/list_schema.go:23` column types `text`, `datetime`, `switch`; `ListColumn` carries `searchable`, `sortable`, `relation`, `select` (`schema_types.go:20-24`). Real YAML exercises each. Search/sort/filter resolve only through compiled allowlists with a primary-key tie-break. Tests re-run: `TestListSchemaCompile`, `TestAlbumsAdminList`, `TestArtistsAdminList`, `TestCollectionsAdminListCRUD`, `TestGenresAdminEdges`: PASS. Caveat: WR-08 (`LOWER(col)` at `query.go:294` on a non-text searchable column; the scaffold still emits `searchable: true` at `artifacts.tmpl:137`). | +| 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` anywhere 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 494), link and unlink (`RelationBeforeLink` at line 684), with explicit pivot writes and no hardcoded pivot names. Tests re-run on PostgreSQL: `TestCollectionsAdminRelation*`, `TestCollectionsAdminRejectsPartial` (the legacy path-less Winter editors partial still fails with "type partial needs a path"), `TestRelationCandidateExclusions`: PASS. **Note:** Phase 10.1 D-09 lifted Phase 9's blanket boot error for `type: partial` in favor of a bare-name, sanitized html/template partial (`form_schema.go:504-524`). The Collections editors tab is still a relation manager, so the criterion holds; the plan-level prohibition is flagged in the Prohibitions table. Caveats: WR-05, WR-07, WR-15. | +| 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:442,531,578,594`, `relation.go:494`. `CRUDService.BulkDelete` (`crud.go:186`) locks the scoped rows in one transaction, refuses a partial selection, and calls `deleteRecord` per row, so GORM and Form*Delete hooks fire per record. `TestBulkDeleteDuplicates`, `Idempotent`, `Rollback`, `TestCRUDHooks` re-run: PASS. Settings use the same `Localize`, writable projection, `lagoon.Fill` and `lagoon.Validate` (`modules/cabana/settings.go`); `TestAdminSettings*` re-run on PostgreSQL: PASS. **CR-01 is now fixed:** `lagoon.convertValue` handles `json.Number` (`fill.go:179-221`), and `save` maps `*lagoon.FillTypeError` to a 422 on the field (`crud.go:324-333`); `TestCRUDFillTypeIsValidation` writes a `type: number` field into an `*int` column, rejects fraction/exponent/overflow/wrong-type with 422 and no row: 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 `` 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. +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 with `-v`: 78 top-level PASS, 0 SKIP, 0 FAIL (36 in `TestPhase09*|TestAdminSettings*|TestAdminMetadata*|TestCollectionsAdmin*`, 42 in `TestAlbumsAdmin*|TestArtistsAdmin*|TestGenresAdmin*|TestStylesAdmin*|TestAdminTracer*|TestAdminAuthLifecycleAssembled`). The 42 equals every function with those prefixes in the package, including `TestAlbumsAdminSearchUsesCommittedArtists` added in 11-08; no Phase 9 test was removed since 2026-09-27 (checked with `git log -p`). The previous report's "79" was a miscount by one. -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. +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. 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 | +| 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). Re-run: PASS | VERIFIED (WR-11 caveat) | +| 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, `Principal.Backend` flag, audience checks; `TestPhase09GuardIsolation`, `TestAdminMetadataRejectsFrontendPrincipal`: PASS | VERIFIED | ### Prohibitions (judgment tier, non-authoritative verdicts) @@ -150,57 +166,58 @@ Backstop (non-inferable) truths: | 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 | No cookie session store and no merge with the frontend user model | not violated (Phase 10 carries the same JWT in a cookie; 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 | Form compiler must not accept `type: partial` | **superseded**: 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`). A recorded later decision, not a regression, but the Phase 9 prohibition text no longer holds literally. | +| 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`) | -| 04 | Equal sort values must not make rows jump across pages | not violated (PK tie-break; adjacent-page tests) | +| 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 (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) | +| 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 (`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 | No PHP partial and no hardcoded Fonoteka pivot names in the framework | not violated (non-test `modules/cabana/*.go` grep: no hits; 10.1 partials are html/template, not PHP) | | 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 | Navigation/settings metadata must not reveal existence, label or target of inaccessible entries | **qualified**: a genres-only admin receives the parent `fonoteka` entry with its albums target (WR-02). 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) | +| 12 | High threats marked mitigated only with a named failing-when-broken test | not violated (`09-SECURITY-REVIEW.md` names a command per threat) | ### 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) | | +| `modules/lagoon/backend_admin_migrations.go` | backend_users/roles, system-role seed | ✓ VERIFIED | Explicit SQL up/down; `TestBackendAdmin*` PASS | +| `modules/cabana/auth.go` | backend provider, login/refresh/logout/me | ✓ VERIFIED | Wired from `Activate`; `BackendUsers` reads only `backend_users` | +| `modules/cabana/commands.go` | admin:create, admin:reset-password | ✓ VERIFIED | `RuntimeCommands` appended in the generated main (`internal/build`) | +| `modules/cabana/http.go` | route mounting, `protect` | ✓ VERIFIED | Mounted from `modules/surf/router.go:522` via `cabana.Activate` | +| `modules/cabana/form_schema.go`, `schema_types.go` | strict typed form compiler | ✓ VERIFIED | goccy/go-yaml strict decode | +| `modules/cabana/list_schema.go`, `filter_schema.go`, `query.go` | list compiler and allowlisted query | ✓ VERIFIED | | +| `modules/cabana/crud.go` | CRUD, projection, hooks, bulk delete | ✓ VERIFIED | CR-01 fixed (Phase 10.1) | +| `modules/lagoon/fill.go` | allowlisted fill used by CRUD and settings | ✓ VERIFIED | `json.Number` conversion, `FillTypeError` | +| `modules/cabana/relation.go` | relation schema and link/unlink | ✓ VERIFIED | | +| `modules/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/.../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 | | -| `fonoteka.go/.../classes/backend_album_collection.go` | D-14 email-to-collection resolver | ✓ VERIFIED | `TestAlbumsAdminCollectionMatch` etc. PASS | +| `fonoteka.go/.../classes/backend_album_collection.go` | D-14 email-to-collection resolver | ✓ VERIFIED | `TestAlbumsAdminCollectionMatch` 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 | +| `modules/surf/router.go` | `modules/cabana/http.go` | `cabana.Activate(app, plugins)` at line 522, then `RegisterMiddleware("summercms.cabana", "backend", ...)` | WIRED | +| `modules/cabana/http.go` guard | `modules/bouncer/jwt.go` | `NewBackendJWTGuard` with backend audience | WIRED | +| `modules/cabana/http.go` handlers | compiled schemas | `s.reg.Get(id)` then list/form/relation | WIRED | +| `modules/cabana/crud.go` | `lagoon.Fill`/`lagoon.Validate` | `save` transaction (line 288; Fill at 324) | WIRED (CR-01 fixed) | +| `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 | | `models/collection/fields.yaml` | `config_relation.yaml` | `relation: editors` compiled at activation | WIRED | | `internal/build/build.go` | `cabana.RuntimeCommands` | generated main | WIRED | @@ -211,19 +228,19 @@ Backstop (non-inferable) truths: | 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 | +| Relation linked/candidates | rows | pivot-joined user query with 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 | +| 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 4 with no test files), including `modules/cabana` 28.7s, `modules/bouncer` 15.8s, `modules/lagoon` 23.5s, `modules/pact`, `modules/phrasebook`, `modules/surf`, `internal/build` 33.1s; 0 FAIL | ✓ PASS | +| Phase 9 framework tests, named | `go test ./modules/cabana -v -run '^(TestBulkDelete(Duplicates\|Idempotent\|Rollback)\|TestCRUDHooks\|TestCRUDFillTypeIsValidation\|TestFormSchema(Compile\|Rejects)\|TestListSchemaCompile\|TestRelationCandidateExclusions\|TestPhase09PermissionMatrix\|TestAdminAuthLifecycle\|TestAdminCreateCommand\|TestAdminResetPasswordCommand)$'` | 13 PASS | ✓ PASS | +| Guard isolation | `go test ./modules/bouncer -run '^TestPhase09GuardIsolation$' -v` | PASS | ✓ PASS | +| Admin tables and numeric fill | `go test ./modules/lagoon -v -run '^(TestFillJSONNumber\|TestFillTypeErrorNamesTheKey\|TestBackendAdmin.*)$'` | 6 PASS | ✓ PASS | +| App vet | `go vet $(go list -f '{{.Dir}}/...' -m)` (fonoteka.go) | exit 0 | ✓ 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) | ok 17.7s; 78 PASS, 0 SKIP, 0 FAIL | ✓ PASS | ### Probe Execution @@ -231,7 +248,7 @@ No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase g | Probe | Command | Result | Status | |---|---|---|---| -| `scripts/check-phase9.sh` | `--self-test` | "phase9 self-test passed", exit 0 | PASS | +| `scripts/check-phase9.sh` | `--self-test` | "refuse: zero tests", "refuse: failed TestPhase09ContractInventory", "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 | @@ -243,52 +260,48 @@ No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe. The phase g | 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-04 | 09-05..09-10, 09-12 | CRUD hooks and per-record bulk delete | ✓ SATISFIED | Truth 5 (CR-01 fixed in Phase 10.1) | | 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. +REQUIREMENTS.md maps exactly these six IDs to Phase 9, so there are no orphaned requirements. ### 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 | +| `modules/cabana/contracts.go` | 127-150 | wildcard requirement unmatched; ALL not ANY | ⚠️ Warning (WR-01) | Latent for future Winter ports | +| `modules/cabana/navigation.go` | 46-57 | denied parent emitted with its target | ⚠️ Warning (WR-02) | Minor metadata disclosure | +| `modules/cabana/auth.go` | 36-73 | user-level permissions ignored | ⚠️ Warning (WR-17) | Winter denies lost at cutover | +| `modules/cabana/query.go`, `internal/build/stubs/artifacts.tmpl` | 294, 137 | `LOWER(col)` on non-text columns; scaffold makes `id` searchable | ⚠️ Warning (WR-08) | Scaffolded controllers 500 on search | +| `internal/build/stubs/artifacts.tmpl` | scaffold controller | no permissions or record source | ⚠️ Warning (WR-09) | Open to all admins once completed naively | +| `modules/cabana/settings.go` | 149 | no test writes a numeric settings field | ℹ️ Info | CR-01's fix is in `lagoon.Fill`, shared by settings; Fill failures there already answer 422 | +| `.planning/.../09-REVIEW-DISPOSITION.md` | ledger | CR-01 still `open` although fixed | ℹ️ Info | Ledger out of date; see human item 1 | -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. +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 defeats a ROADMAP success criterion. No `TBD`/`FIXME`/`XXX` in any covered implementation file (grep: no hits). ### Human Verification Required -#### 1. Decide CR-01 (numeric fields cannot be saved) +#### 1. Triage the open review findings, starting with the AUTH-08 ones -**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). +**Test:** Set dispositions in 09-REVIEW-DISPOSITION.md, where all 32 findings are still `open`. Record CR-01 as fixed (Phase 10.1, `c3efbc3` + `e60e697`, covered by `TestCRUDFillTypeIsValidation`). +**Expected:** WR-01, WR-02, WR-14 and WR-17 explicitly fixed or deferred with a reason. Their code is unchanged at HEAD. 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 +#### 2. 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). +**Expected:** Pay attention to the superseded 09-03 `type: partial` prohibition (lifted by Phase 10.1 D-09) and the two qualified verdicts (09-11 navigation target, 09-12 per-invocation zero-test detection). **Why human:** Judgment-tier prohibitions need human resolution. +The previous item "Decide CR-01" is resolved by code and test evidence and is no longer a separate item. 09-UAT.md still lists it as pending. + ### 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. +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. Since the last report the critical review finding CR-01 was fixed in Phase 10.1. -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. +The phase is still not `passed` because two human decisions are open. All review findings are untriaged in the ledger, and four warnings bear on the Winter permission semantics that AUTH-08 and D-03 promise. And 24 judgment-tier prohibitions need sign-off, one of which a later phase deliberately superseded. Neither blocks Płytarium today. --- -_Verified: 2026-09-28T00:10:00Z_ +_Verified: 2026-10-01T18:35:29Z_ _Verifier: Claude (gsd-verifier)_