docs(03): create phase plan

This commit is contained in:
Jakub Zych
2026-09-17 18:40:13 +02:00
parent de99bf3caa
commit 15a8389e46
10 changed files with 801 additions and 8 deletions

View File

@@ -116,7 +116,25 @@ Plans:
3. A JWT-guarded `GET /_fonoteka/api/v1/genres` route returns a real `Genre` row read from Postgres.
4. The response is diffed byte-for-byte against a fixture recorded from the live PHP backend using the Phase 2 harness, and the diff is green.
**Plans**: TBD
**Plans**: 4 plans
Plans:
**Wave 1**
- [ ] 03-01-PLAN.md — Boot the Postgres-backed JWT genre route through both plugins
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 03-02-PLAN.md — Port active-collection counts, validation and Polish ordering
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 03-03-PLAN.md — Add typed params, isolated rollback and the first real parity pass
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 03-04-PLAN.md — Complete unit, integration and security verification
### Phase 4: CLI scaffolding, i18n and mail

View File

@@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
status: executing
stopped_at: Phase 3 context gathered
last_updated: "2026-09-17T15:47:07.718Z"
last_activity: 2026-09-17
last_updated: "2026-09-17T16:39:57.961Z"
last_activity: 2026-09-17 -- Phase 3 planning complete
progress:
total_phases: 15
completed_phases: 2
total_plans: 9
total_plans: 13
completed_plans: 9
percent: 13
---
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 3
Plan: Not started
Status: Ready to plan
Last activity: 2026-09-17
Status: Ready to execute
Last activity: 2026-09-17 -- Phase 3 planning complete
Progress: [██████████] 100% of realized plans (9/9); 2/15 roadmap phases

View File

@@ -0,0 +1,159 @@
---
phase: 03-first-vertical-slice-genres-end-to-end
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- go.mod
- go.sum
- bonfire/command.go
- internal/build/build.go
- pact/capabilities.go
- party/registry.go
- lagoon/connection.go
- lagoon/migrations.go
- lagoon/commands.go
- surf/router.go
- surf/middleware.go
- bouncer/jwt.go
- bouncer/context.go
- cmd/summer/main.go
- ../fonoteka.go/go.mod
- ../fonoteka.go/go.work
- ../fonoteka.go/README.md
- ../fonoteka.go/summer.yaml
- ../fonoteka.go/config/app.yaml
- ../fonoteka.go/parity/synthetic_test.go
- ../fonoteka.go/parity/genre_smoke_test.go
- ../fonoteka.go/app/app.go
- ../fonoteka.go/main.go
- ../fonoteka.go/plugins.gen.go
- ../fonoteka.go/plugins/golem15/user/plugin.go
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
- ../fonoteka.go/plugins/golem15/user/migrations.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/migrations.go
- ../fonoteka.go/plugins/golem15/fonoteka/genre.go
autonomous: true
requirements: [DATA-01, DATA-02, HTTP-01, HTTP-02, QA-04]
must_haves:
truths:
- "D-07 D-08 D-09 D-10 D-11: a valid HS256 token for a persisted user reaches the route; missing, malformed, expired, wrong-algorithm, bad-signature and unknown-user tokens do not."
- "D-12 D-13 D-14: golem15.fonoteka requires golem15.user and mounts GET /_fonoteka/api/v1/genres through the named middleware pipeline, including jwt.auth and inv.must-change-password."
- "D-16 D-17 D-18 D-19: a fresh Polish-ICU Postgres database migrates the user and Fonoteka sets in dependency order and contains the 15 canonical genres without a seeder command or AutoMigrate."
- "DATA-01: GORM and application services use the same pgx-stdlib *sql.DB; the River listener pool is a documented Phase 11 seam."
artifacts:
- path: lagoon/connection.go
provides: One checked pgx stdlib SQL pool passed to GORM
- path: lagoon/migrations.go
provides: Per-plugin gormigrate sets and version tables
- path: surf/router.go
provides: Group declarations compiled to net/http ServeMux
- path: bouncer/jwt.go
provides: Pinned HS256 verifier and request identity
- path: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
provides: First real app route mounted through the user plugin
- path: ../fonoteka.go/app/app.go
provides: Reusable app boot seam shared by generated main and parity tests
key_links:
- from: party/registry.go
to: pact/capabilities.go
via: optional capability discovery in Requires order
- from: lagoon/connection.go
to: backpack/app.go
via: app-scoped *sql.DB and *gorm.DB publication
- from: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
to: ../fonoteka.go/plugins/golem15/user/plugin.go
via: Requires and named jwt.auth lookup
---
<objective>
**As a** Fonoteka user with a valid token, **I want to** request the seeded genre list from a real Postgres backed Go app, **so that** the first complete config → plugin → DB → auth → HTTP path exists.
Purpose: Establish a runnable vertical slice on the actual app binary. Plan 02 refines the query and Plan 03 makes the recorded PHP parity fixture green.
Output: Shared database and migration runtime, ServeMux group and middleware registry, HS256 verifier, two app plugins, and a JWT protected genre route.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-PATTERNS.md
<interfaces>
`party.Plugin` has `ID`, `Requires`, `Register(*backpack.App) error`, `Boot(*backpack.App) error`; `party.Activate` returns dependency ordered plugins. `pact.HasConfig` and `HasCommands` are current optional capability patterns. `backpack.App` publishes app scoped services. `bonfire.Command` uses string flags and `Run(context.Context, bonfire.Input, bonfire.Output) error`; `validCommandName` currently permits only `build` and `dev` as bare names. `internal/build/build.go` generates app `main.go` and blank imports. The app parity suite already has a pgx `*sql.DB` from testcontainers.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Initialize the real app database through plugin migrations</name>
<files>go.mod, go.sum, bonfire/command.go, internal/build/build.go, pact/capabilities.go, party/registry.go, lagoon/connection.go, lagoon/migrations.go, lagoon/commands.go, cmd/summer/main.go, ../fonoteka.go/go.mod, ../fonoteka.go/go.work, ../fonoteka.go/README.md, ../fonoteka.go/summer.yaml, ../fonoteka.go/config/app.yaml, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/main.go, ../fonoteka.go/plugins.gen.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/migrations.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/migrations.go, ../fonoteka.go/plugins/golem15/fonoteka/genre.go</files>
<read_first>go.mod, bonfire/command.go, cmd/summer/main.go, internal/build/build.go, pact/capabilities.go, party/registry.go, backpack/app.go, compass/config.go, compass/env.go, ../fonoteka.go/go.mod, ../fonoteka.go/README.md, ../fonoteka.go/parity/synthetic_test.go, examples/hello/main.go, examples/hello/summer.yaml, examples/hello/plugins/base/config/config.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/version.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.9/rename_taxonomy_and_notes_columns.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/seed_genre_and_various_artist_taxonomy.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md</read_first>
<action>Build the smallest useful command path: `fonoteka migrate` and `summer migrate` in an app directory use the same `bonfire` command contract to load config, activate generated plugin imports, open `database.dsn` (env `SUMMER_DATABASE__DSN`) through `sql.Open("pgx", ...)`, `PingContext`, publish that exact `*sql.DB` and a GORM handle opened with `postgres.Config{Conn: sqlDB}`, then run each plugin's ordered `[]*gormigrate.Migration` from `pact.HasMigrations` in `party.Activate` order. Extend `bonfire.validCommandName` for bare `serve` and `migrate`; keep colon names for `migrate:rollback` and `migrate:status`. The `summer` tool may delegate app runtime commands to the generated binary, but cannot import app packages or recurse. Give each plugin a validated, separate history table (`summer_migrations_golem15_user`, `summer_migrations_golem15_fonoteka`) with transactions. Create the app workspace and two modules; `golem15.fonoteka.Requires()` returns `[]string{"golem15.user"}`. The user set creates minimal `users` with `must_change_password`; its config file declares `jwt.secret` under the merged `golem15.user.jwt.secret` path (env `SUMMER_GOLEM15__USER__JWT__SECRET`) without a production default. The Fonoteka set creates full `golem15_fonoteka_genres` directly, with no `item_categories` detour, plus minimal collections/albums/editor pivot/context tables required by D-01–D-03, then seeds the 15 slug/name pairs in PHP order in a separate idempotent data migration with a reverse callback. Name the PHP update files in migration comments. Use explicit DDL, never `AutoMigrate`. Do not create a River listener pool. Document `CREATE DATABASE ... TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'` in app README/config setup, and make boot check Postgres 16 `pg_database.datlocprovider='i'` and `daticulocale='pl-PL'` before migration. Set the existing Postgres 16 testcontainer's `POSTGRES_INITDB_ARGS` to `--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8` via `testcontainers.WithEnv`, so both synthetic and real app tests use the same database default. Resolve only the modules named in RESEARCH.md; inspect `go.mod`/`go.sum` and official upstream paths because slopcheck's age heuristic flagged recent GORM/gormigrate versions.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- A fresh Postgres 16 database with ICU `pl-PL` accepts `fonoteka migrate`, yields exactly 15 genre slugs in PHP seed order, and has separate user/Fonoteka history tables.
- A libc or non-Polish database fails before migrations with an actionable locale error; no migration calls `AutoMigrate`.
- The same `*sql.DB` is supplied to GORM and app services; the separate River listener pool is deferred to Phase 11.
- Both root and app `go vet ./...` and `go test ./...` exit 0 before commit.
</acceptance_criteria>
<done>A developer can initialize the actual Fonoteka Go database with one command and immediately query the 15 migrated genres.</done>
</task>
<task type="auto">
<name>Task 2: Serve the seeded genre list behind real cross-plugin JWT middleware</name>
<files>pact/capabilities.go, party/registry.go, surf/router.go, surf/middleware.go, bouncer/jwt.go, bouncer/context.go, lagoon/commands.go, internal/build/build.go, ../fonoteka.go/app/app.go, ../fonoteka.go/parity/genre_smoke_test.go, ../fonoteka.go/main.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/genre.go</files>
<read_first>pact/capabilities.go, party/registry.go, bonfire/command.go, internal/build/build.go, backpack/app.go, towel/context.go, ../fonoteka.go/main.go, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.php, /media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Http/Middleware/BaseMiddleware.php, /media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Claims/Expiration.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/RequirePasswordChange.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php, /media/nvme/dev/golem15/fonoteka/config/jwt.php, ../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md</read_first>
<action>Add `pact.HasRoutes`, `HasMiddleware`, and `HasModels` as optional interfaces, then assemble a per-app `surf` router on `http.NewServeMux` after all plugins register. Implement `Group(prefix, surf.Use(names...), callback)`, `Get(path, http.HandlerFunc)`, and per-route named middleware; compile the genre declaration as `GET /_fonoteka/api/v1/genres`. Resolve middleware names at boot, with a missing name error containing the registering plugin ID and name. Wrap every route in recover → CORS → locale → auth group → must-change-password → org context → rate limit → handler; recover returns opaque JSON 500, CORS preflight uses configured `http.cors.allowed_origins` and bypasses auth, locale stores `Accept-Language` in context, org context is a slot, rate limit is a no-op interface. In `bouncer`, parse JWT with `WithValidMethods([]string{"HS256"})` and `WithExpirationRequired`, require nonempty `sub`, load the corresponding persisted user through a `UserProvider`, and store it through an unexported context key. `golem15.user` registers `jwt.auth`, reads a nonempty secret from `golem15.user.jwt.secret` with `SUMMER_GOLEM15__USER__JWT__SECRET` overlay, and reproduces PHP's 401 JSON `{"error":true,"message":...}`. Use `Token not provided` for a missing bearer, `Token has expired` for an expired token, and `User not found` for an unknown subject as named in the PHP JWT package; capture the PHP response for malformed and bad-signature cases on the isolated Phase 2 PHP server before finalizing those translations. `golem15.fonoteka` registers `inv.must-change-password`, returns `{"error":"Password change required","must_change_password":true}` with 423, and references both names in its group. The initial handler reads all migrated genres through GORM and emits a dedicated `data` DTO with `id`, `name`, `slug`, integer `album_count:0`, an initialized array, `Content-Type: application/json` and `Cache-Control: no-cache, private`; Plan 02 replaces the zero count with the complete scoped query. Expose a graceful signal-aware `serve` command on both binaries. Put the app boot and handler assembly behind an importable `../fonoteka.go/app` package that accepts config, plugin IDs and the existing SQL pool; generated main and parity tests call the same seam, with compiled plugin imports kept in the appropriate entry point. Add a focused `genre_smoke_test.go` in the existing Postgres test package that boots this seam and sends one valid-token HTTP request without marking corpus routes ported yet. Use a fixed test-only token secret; token issuing remains outside production APIs.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- A valid HS256 token for a persisted user returns 200 from `GET /_fonoteka/api/v1/genres` and a nonempty `data` array read from Postgres.
- Missing, malformed, expired, wrong-algorithm, bad-signature, missing-subject and unknown-user tokens never reach the handler; a locked user receives PHP's 423 JSON.
- Missing `jwt.auth` or `inv.must-change-password` fails boot with the plugin and middleware name; the framework imports no Fonoteka package.
- Both binaries expose `serve`; `summer build` regenerates the app entry point without losing runtime commands; root and app vet/test exit 0.
</acceptance_criteria>
<done>A token-bearing user can fetch the real seeded genre list through the full named middleware path.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Config and Go module resolution → process | Secret, DSN and dependency metadata enter the runtime. |
| Bearer token → user lookup → handler | Untrusted claims cross into authenticated request context. |
| Plugin declarations → ServeMux | A missing or misordered named guard could expose a route. |
## STRIDE Threat Register
| Threat ID | Category | Severity | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-01 | Tampering | high | migrations/locale | mitigate | Explicit immutable DDL, per-plugin history tables, database locale check before migrations. |
| T-03-02 | Elevation of privilege | high | `surf` middleware registry | mitigate | Fail boot on missing guard, preserve fixed stage order, inspect route registration. |
| T-03-03 | Spoofing | high | `bouncer`/`jwt.auth` | mitigate | Pin HS256, require exp/sub, verify signature and persisted user, reject empty secret. |
| T-03-SC | Tampering | medium | Go module resolution | mitigate | Official upstream path verification plus `go.mod`/`go.sum` review; document slopcheck's recent-version false positives. |
</threat_model>
<verification>
Demonstrate database migration and one authenticated HTTP request on Postgres 16 ICU `pl-PL`. Run root and app vet/test after each task; preserve Phase 2 parity tests even though the route stays pending until Plan 03.
</verification>
<success_criteria>
The app binary migrates and serves the 15 seeded genres from Postgres through a real JWT guard and cross-plugin named middleware, using the same SQL pool as GORM and no `AutoMigrate`.
</success_criteria>
<output>
Create `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-01-SUMMARY.md` after completion.
</output>

View File

@@ -0,0 +1,128 @@
---
phase: 03-first-vertical-slice-genres-end-to-end
plan: 02
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- lagoon/order.go
- ../fonoteka.go/plugins/golem15/fonoteka/active_collection.go
- ../fonoteka.go/plugins/golem15/fonoteka/genre.go
- ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/migrations.go
- ../fonoteka.go/parity/genre_integration_test.go
autonomous: true
requirements: [DATA-01, HTTP-02, QA-04]
must_haves:
truths:
- "D-01 D-02 D-03: the genre count comes from albums accessible to the authenticated user in the active collection; owner and editor access are preserved and foreign albums never count."
- "D-04 D-05: `non_empty=1` keeps only positive-count genres; invalid values return PHP's 422 envelope; a Postgres test proves positive, zero and forbidden counts."
- "D-06: a reusable allow listed lagoon ordering helper is called at the PHP PolishOrder call site and uses the database default ICU pl-PL locale confirmed by the user."
- "The handler returns a dedicated DTO with id, name, slug and integer album_count under data, with [] for an empty result."
artifacts:
- path: lagoon/order.go
provides: Safe database-default ordering helper
- path: ../fonoteka.go/plugins/golem15/fonoteka/active_collection.go
provides: Default active collection selection and access predicate
- path: ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
provides: PHP-fidelity aggregate query and response
- path: ../fonoteka.go/parity/genre_integration_test.go
provides: Postgres proof unavailable from the zero-count fixture
key_links:
- from: ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
to: ../fonoteka.go/plugins/golem15/fonoteka/active_collection.go
via: authenticated user and active collection lookup
- from: ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
to: lagoon/order.go
via: allow listed name ordering call
- from: ../fonoteka.go/parity/genre_integration_test.go
to: ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
via: real HTTP request over testcontainers Postgres
---
<objective>
**As a** Fonoteka user, **I want to** see the global genre list with album counts from my active collection, **so that** the API has the same tenant scoped behavior as PHP.
Purpose: Replace the Plan 01 zero-count placeholder with the complete PHP read path and prove the path the recorded fixture cannot exercise.
Output: Active collection service, scoped aggregate query, query validation, database-default Polish order helper, and a nonzero-count integration test.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-01-SUMMARY.md
<interfaces>
Plan 01 provides app-scoped `*gorm.DB`, authenticated user in request context, `golem15_fonoteka_genres`, minimal users/collections/albums/editor/context tables, and a mounted `GET /_fonoteka/api/v1/genres` handler. The PHP controller uses an accessible-albums subquery narrowed to the active collection, groups by `genre_id`, left joins counts to all genres, and calls `PolishOrder::apply` before optional `non_empty` filtering. The confirmed Postgres choice is a database-level ICU `pl-PL` locale, so the helper emits an ordinary ORDER BY.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Count only albums visible in the active collection</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/genre.go, ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/migrations.go, ../fonoteka.go/parity/genre_integration_test.go</files>
<read_first>../fonoteka.go/plugins/golem15/fonoteka/genre.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/migrations.go, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/parity/parity_test.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveCollectionResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserCollectionContext.php, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md</read_first>
<action>Port only `ActiveCollectionResolver::resolve`'s JWT default path: read `golem15_fonoteka_user_collection_contexts` for the authenticated user, accept the selected collection only when the user owns it or appears in `golem15_fonoteka_collection_editors`, otherwise choose the lowest-ID accessible real collection (`kind='collection'`). Store the fallback context if the PHP path does; do not add token pinning, invitation handling, switching or auto-provisioning. Define the owner OR editor condition in a grouped predicate, then AND it with `collection_id = active.id` in the albums subquery. In the genre handler, use GORM to select album IDs visible through that predicate, aggregate `COUNT(*)` by `genre_id`, left join to all genres, select exactly `id`, `name`, `slug`, `COALESCE(album_count,0)`, and serialize through a separate `GenreAggregate` DTO. Keep Postgres column aliases and `ORDER BY` injection safe. If Plan 01's minimal schema lacks a column needed by this path, append a new numbered ALTER migration; never rewrite a Plan 01 migration. Add a focused testcontainers HTTP test with Alice's owned collection, an editor-shared collection, a foreign collection, positive and zero genre counts, and an active-context switch. Assert foreign albums do not enter Alice's count.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./... -run 'TestGenre' -count=1)</automated></verify>
<acceptance_criteria>
- Counts for Alice's active collection equal the inserted accessible albums for each genre; another user's albums remain excluded even when the genre IDs overlap.
- An editor-shared collection is accessible, a foreign collection is not, and an invalid stored context falls back to the lowest-ID accessible real collection.
- SQL keeps owner/editor as one grouped OR before collection and genre restrictions; an initialized zero-count genre remains in the list.
- App and framework vet/test exit 0 at commit.
</acceptance_criteria>
<done>The authenticated list reports real tenant scoped counts rather than a constant zero.</done>
</task>
<task type="auto">
<name>Task 2: Match PHP filter, validation, ordering and JSON shape</name>
<files>lagoon/order.go, ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go, ../fonoteka.go/parity/genre_integration_test.go</files>
<read_first>lagoon/connection.go, ../fonoteka.go/plugins/golem15/fonoteka/genre_handler.go, ../fonoteka.go/parity/genre_integration_test.go, ../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/PolishOrder.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php, /media/nvme/dev/golem15/fonoteka/modules/system/lang/pl/validation.php, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md</read_first>
<action>Interpret absent or `non_empty=0` as the full list; `non_empty=1` adds `COALESCE(counts.album_count,0) > 0`; `non_empty` outside `0|1` returns status 422 with `{"error":"Validation failed","errors":{"non_empty":[...]}}` using the PHP Polish `validation.in` message from the referenced language file. Match PHP's handling of duplicate query keys by checking the isolated PHP endpoint before encoding a rule in Go. Add a reusable `lagoon` order helper that accepts only configured qualified columns and `asc|desc`, rejects every other identifier/direction, and applies normal `ORDER BY golem15_fonoteka_genres.name ASC` without `COLLATE`, relying on the confirmed database default ICU `pl-PL`. Call the helper where the PHP controller calls `PolishOrder::apply`. Keep a dedicated DTO with integer `id` and `album_count`, a `data` array (never null), `Content-Type: application/json`, and `Cache-Control: no-cache, private`. Extend the Postgres test to compare the complete 15-name sequence from the recorded fixture and a positive-count `non_empty=1` response; a database with the wrong provider/locale must fail before the query.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./... -run 'TestGenre' -count=1)</automated></verify>
<acceptance_criteria>
- `non_empty=1` returns only positive-count genres and invalid `non_empty` returns 422 with `error` and `errors.non_empty` in PHP shape.
- Ordinary GORM ordering on ICU `pl-PL` database yields the exact 15-row order recorded by PHP; the handler calls the allow listed `lagoon` helper.
- An empty result is `{"data":[]}`, and `id`/`album_count` remain JSON numbers rather than strings.
- Root and app vet/test exit 0 at commit.
</acceptance_criteria>
<done>The route's query, filter, ordering and envelope match the PHP behavior beyond the zero-count fixture.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Authenticated user and context → SQL scope | An incorrect OR/AND grouping can expose another tenant's album counts. |
| Query values and order identifiers → SQL | Untrusted filter values and dynamic ORDER BY input can alter query behavior. |
## STRIDE Threat Register
| Threat ID | Category | Severity | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-04 | Information disclosure | high | active collection and album count query | mitigate | Group owner/editor OR, AND active collection, test foreign albums and invalid context. |
| T-03-05 | Tampering | medium | `lagoon` ordering and `non_empty` | mitigate | Allow listed identifiers/directions, explicit `0|1` validation, no raw user SQL. |
| T-03-01 | Tampering | medium | appended migrations | mitigate | Never edit a shipped migration; append reversible ALTER if needed. |
</threat_model>
<verification>
Run focused app Postgres HTTP tests for owned, editor, foreign and zero counts; invalid filter; 15-name order; and wrong-locale startup. Run both modules' vet/test after each task.
</verification>
<success_criteria>
The real JWT route returns the PHP-shaped, collection-scoped aggregate with tested nonzero counts, `non_empty`, and Polish database ordering.
</success_criteria>
<output>
Create `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-02-SUMMARY.md` after completion.
</output>

View File

@@ -0,0 +1,132 @@
---
phase: 03-first-vertical-slice-genres-end-to-end
plan: 03
type: execute
wave: 3
depends_on: ["03-02"]
files_modified:
- surf/params.go
- lagoon/migrations.go
- lagoon/commands.go
- examples/hello/main.go
- examples/hello/plugins/greeter/plugin.go
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/genres_seed_test.go
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/README.md
autonomous: true
requirements: [DATA-02, HTTP-01, HTTP-02, QA-04]
must_haves:
truths:
- "D-15: surf parses integer path params, applies regex/enum constraints, and gives the same 404 for malformed and unknown ownership-scoped IDs; examples/hello exercises the API."
- "D-19: migrate:rollback --plugin=golem15.fonoteka only reverses that plugin's latest migration and migrate:status reports each plugin's applied IDs."
- "D-20: the app-owned genres seed hook supplies Alice, active collection, a test-only JWT and colliding IDs; the unmodified PHP fixture passes through the real app handler."
- "The corpus records exactly one ported/passing Go route and 153 pending routes; pending never counts as passing."
artifacts:
- path: surf/params.go
provides: Reusable typed and constrained ServeMux path parameters
- path: lagoon/commands.go
provides: Reversible per-plugin migration CLI
- path: ../fonoteka.go/parity/genres_seed_test.go
provides: Temporary trusted Go-only genre fixture seed hook
- path: ../fonoteka.go/parity/parity_test.go
provides: Real app handler replay and honest corpus counts
- path: ../fonoteka.go/parity/manifest.yaml
provides: One declared ported route and temporary hook
key_links:
- from: ../fonoteka.go/parity/parity_test.go
to: ../fonoteka.go/app/app.go
via: `newTarget` boots the real app over the TestMain SQL pool
- from: ../fonoteka.go/parity/manifest.yaml
to: ../fonoteka.go/parity/genres_seed_test.go
via: `seed_hook: genres` allow-list dispatch
- from: ../fonoteka.go/parity/parity_test.go
to: tide/replay.go
via: unchanged `tide.ReplayFlow` against recorded fixture
---
<objective>
**As a** port developer, **I want to** roll back one plugin, parse future typed routes, and replay the PHP genres fixture against the real app, **so that** the first route is demonstrably compatible and the platform is usable for later routes.
Purpose: Complete the phase's reusable CLI/route seams and turn exactly one recorded route from pending into an honest Go pass.
Output: Typed params, migration rollback/status, trusted parity seed hook, real replay target, and coverage accounting.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-02-SUMMARY.md
<interfaces>
`tide.Route.SeedHook` is the YAML `seed_hook` field, `seedHooks` in app `parity_test.go` is a trusted Go function map, and `replayPortedRoute` currently replays the global PHP bootstrap seed before every ported case. `newTarget(t, db)` currently returns a synthetic handler. `TestParityCorpus` assumes 154 pending and zero passes; those assertions must change when the genres route becomes ported. `tide.Store.Set(name,value)` holds the test-only JWT and captured-ID substitutions. The fixture is `fixtures/routes/get_genres_jwt.yaml` and must remain byte-for-byte unmodified.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Expose reversible plugin migrations and typed route parameters</name>
<files>surf/params.go, lagoon/migrations.go, lagoon/commands.go, examples/hello/main.go, examples/hello/plugins/greeter/plugin.go, ../fonoteka.go/README.md</files>
<read_first>surf/router.go, surf/middleware.go, lagoon/migrations.go, lagoon/commands.go, bonfire/command.go, examples/hello/main.go, examples/hello/plugins/greeter/plugin.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md</read_first>
<action>Complete `migrate:status` and `migrate:rollback --plugin=golem15.fonoteka` on the shared bonfire runtime: status lists the applied migration IDs from each plugin history table; rollback invokes only that plugin's `gormigrate.RollbackLast()` and leaves `golem15.user` history and rows intact. Keep the same capability/order path as `migrate` and make a missing/unknown plugin a nonzero named error. In `surf`, expose an integer accessor for ServeMux `Request.PathValue(name)` with positive-ID parsing and a 404 result for malformed IDs, and route constraints for allow listed regex and enum values matching PHP `->where(...)` use. Validate constraints at registration, not by constructing SQL or regex from request text. Add a small `examples/hello` route with `{id}` and an enum or regex constraint; its handler returns 404 for both malformed and unknown IDs, exercising the public API. Add app README examples for fresh ICU `pl-PL` database creation, `migrate`, `migrate:status`, `migrate:rollback --plugin=golem15.fonoteka`, and `serve`; state that rollback of the seed removes the 15 rows without undoing the user plugin. Preserve the app's generated entry point on `summer build`.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- `migrate:status` reports separate user and Fonoteka migration IDs; `migrate:rollback --plugin=golem15.fonoteka` changes only Fonoteka history/data.
- The `examples/hello` typed route serves a known positive integer, returns 404 for malformed and unknown IDs, and rejects a route value outside its declared constraint.
- Both modules' vet/test exit 0 before commit.
</acceptance_criteria>
<done>A developer can inspect and reverse a single plugin's migration, and route authors can use typed constrained path values.</done>
</task>
<task type="auto">
<name>Task 2: Turn the recorded genres route into one honest Go parity pass</name>
<files>../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/genres_seed_test.go, ../fonoteka.go/parity/manifest.yaml</files>
<read_first>../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml, ../fonoteka.go/parity/fixtures/seed/bootstrap.yaml, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/main.go, tide/manifest.go, tide/replay.go, tide/variables.go, .planning/phases/02-api-parity-harness-bootstrap/02-CONTEXT.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md</read_first>
<action>Replace `newTarget(t, db)`'s synthetic handler with the booted real `../fonoteka.go/app` handler using the TestMain `*sql.DB` and the fixed test-only secret; test code imports the compiled plugins and loads the app manifest IDs rather than duplicating their list. Apply both plugin migrations once per isolated test state. Add idempotent `seedHooks["genres"]` in app test code: insert Alice and an owned real collection through GORM, persist active context, mint only the test JWT into `jwt:alice`, and set `id:wishlist-album=2`, `id:genre=4`, and `id:token=1` so the recorded genre IDs resolve exactly; derive those three values from the PHP seed order and fixture, not user input. Change only the `GET /_fonoteka/api/v1/genres jwt` manifest entry to `status: ported` and `seed_hook: genres`, with a comment naming the temporary replacement routes (register/login and `POST /_fonoteka/api/v1/genres`). For a ported route with a trusted route seed hook, skip the global PHP bootstrap flow that calls unported Go endpoints; invoke the route hook instead, while preserving global seed behavior for routes without a hook. Keep the 154-route manifest and recorded fixture content unchanged otherwise. Update `TestParityCorpus`'s coverage assertion to require 154 recorded, exactly 1 ported/passing and 153 pending after successful replay; increment passing only after the ported subtest actually succeeds. Retain `ported-mismatch` and `ported-missing-fixture` failure checks, and add a deliberate mutated response check to prove a false pass is caught. Do not edit `tide` or its normalization/scrubber rules.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./parity -run 'TestParityCorpus' -count=1 &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- The unchanged `get_genres_jwt.yaml` replays against the real handler with status 200, expected headers, exact genre order and JSON token values under Phase 2's diff semantics.
- Corpus output/assertions report 154 recorded, 1 ported/passing, 153 pending, 0 failing and 0 unrecorded; a mutated real response fails its subtest.
- The app hook uses a fixed test secret and in-memory variable store, writes no live credential to git, and does not replay unported global bootstrap endpoints for this route.
- Root and app vet/test exit 0; no generic `tide` file or recorded fixture changed.
</acceptance_criteria>
<done>The first real Go route passes the recorded PHP fixture while the other 153 remain honestly pending.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| CLI plugin argument → migration history | An arbitrary plugin name must not select or alter another plugin's migration set. |
| URL path value → handler | Malformed or unknown identifiers must not leak ownership information. |
| Recorded fixture variables → test request | JWT and captured IDs are substituted only from trusted test state, never committed as live secrets. |
## STRIDE Threat Register
| Threat ID | Category | Severity | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-01 | Tampering | high | migration rollback | mitigate | Validate plugin ID, isolate history table, test user rows and history unchanged. |
| T-03-07 | Information disclosure | medium | typed route IDs | mitigate | Return the same 404 for malformed and unknown ownership-scoped IDs. |
| T-03-06 | Spoofing/Tampering | high | parity seed and coverage | mitigate | Test-only secret, trusted hook map, no fixture rewrite, pass count increments only after real replay. |
</threat_model>
<verification>
Run both modules' vet/test, focused real corpus replay, migration rollback/status integration, and hello typed-route tests. Confirm only the genres manifest entry changes to ported.
</verification>
<success_criteria>
One PHP fixture passes the real Go route; per-plugin rollback and typed params work; the remaining 153 routes remain pending.
</success_criteria>
<output>
Create `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-03-SUMMARY.md` after completion.
</output>

View File

@@ -0,0 +1,138 @@
---
phase: 03-first-vertical-slice-genres-end-to-end
plan: 04
type: execute
wave: 4
depends_on: ["03-03"]
files_modified:
- lagoon/connection_test.go
- lagoon/migrations_test.go
- lagoon/order_test.go
- surf/router_test.go
- surf/middleware_test.go
- surf/params_test.go
- bouncer/jwt_test.go
- bonfire/command_test.go
- examples/hello/hello_test.go
- ../fonoteka.go/parity/genre_integration_test.go
- ../fonoteka.go/parity/genre_security_test.go
- ../fonoteka.go/parity/parity_contract_test.go
- scripts/check-phase3.sh
- .planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
- .planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md
autonomous: true
requirements: [DATA-01, DATA-02, HTTP-01, HTTP-02, QA-04]
must_haves:
truths:
- "D-01 through D-06: independent Postgres cases prove active-collection scoping, nonzero and zero counts, `non_empty`, 422, and database-default Polish order."
- "D-07 through D-15: auth and middleware tests prove no unauthenticated or locked request reaches the handler, all seven stages are ordered, and typed/constraint params give safe 404 behavior."
- "D-16 through D-20: migration isolation/up/down/seed and the unchanged recorded fixture pass with honest one-of-154 corpus accounting."
- "The roadmap's security-load-bearing JWT guard receives a documented security review with every high-severity threat mitigated or explicitly reported."
- "Root and app vet/test/race checks pass; the Phase 2 parity harness regression remains green."
artifacts:
- path: bouncer/jwt_test.go
provides: Adversarial token verification coverage
- path: surf/middleware_test.go
provides: Pipeline and missing guard boot coverage
- path: ../fonoteka.go/parity/genre_security_test.go
provides: Cross-plugin auth and tenant isolation coverage
- path: scripts/check-phase3.sh
provides: Repeatable full gate for both repositories
- path: .planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md
provides: Security review evidence and findings
key_links:
- from: scripts/check-phase3.sh
to: ../fonoteka.go/parity/parity_test.go
via: focused ported-route and full corpus Go test
- from: .planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md
to: bouncer/jwt_test.go
via: threat-to-test evidence
---
<objective>
**As a** maintainer, **I want to** run one repeatable gate for the real genres route and its security boundaries, **so that** later endpoint work cannot silently break the first vertical slice.
Purpose: Finish the phase with dedicated meaningful unit, integration, parity, and security tests as required by CLAUDE.md.
Output: Tests for each risk, one check script, validation evidence, and security review findings.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-01-SUMMARY.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-02-SUMMARY.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-03-SUMMARY.md
<interfaces>
Plans 01–03 produce `lagoon`, `surf`, `bouncer`, a two-plugin app, a Postgres-backed genre handler, and a single ported route in `TestParityCorpus`. Phase 2's `scripts/check-phase2.sh` is the baseline harness regression command. All tests in this plan must assert externally observable behavior or security invariants, not duplicate source implementation details.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Cover framework boundaries with adversarial unit and integration tests</name>
<files>lagoon/connection_test.go, lagoon/migrations_test.go, lagoon/order_test.go, surf/router_test.go, surf/middleware_test.go, surf/params_test.go, bouncer/jwt_test.go, bonfire/command_test.go, examples/hello/hello_test.go</files>
<read_first>lagoon/connection.go, lagoon/migrations.go, lagoon/order.go, surf/router.go, surf/middleware.go, surf/params.go, bouncer/jwt.go, bouncer/context.go, bonfire/command.go, examples/hello/hello_test.go, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md</read_first>
<action>Add behavior-focused tests for one pgx stdlib SQL pool shared with GORM and closed on shutdown; wrong ICU provider/locale boot failure; two migration sets with separate history, ordered up/down and rollback isolation; no `AutoMigrate` schema source; allow listed ORDER BY identifiers/direction; ServeMux method/path/group and per-route middleware composition; fixed recover → CORS → locale → auth → password gate → org → rate → handler order, including preflight and panic 500 without leaked internals; missing named middleware boot failure; integer, regex and enum params with malformed and unknown ID 404; and command names `serve`, `migrate`, `migrate:status`, `migrate:rollback`. For JWT, test valid persisted subject plus missing token, malformed token, `alg:none`, wrong HMAC algorithm, bad signature, absent/expired `exp`, absent `sub`, unknown user, empty secret, and absence of token/secret text in errors. Use `httptest` and isolated Postgres where behavior requires the DB; do not create tests that merely assert a helper calls another helper. Keep root and app vet/test green before the commit.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; go test -race ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- Every high-severity framework threat T-03-01, T-03-02, and T-03-03 has at least one failing-when-broken test.
- Both malformed and unknown typed IDs return 404, missing middleware fails boot, and an unauthenticated request cannot reach the genre handler.
- Root vet/test/race and app vet/test exit 0 at commit.
</acceptance_criteria>
<done>The reusable runtime's database, routing and authentication invariants are testable independently of the PHP fixture.</done>
</task>
<task type="auto">
<name>Task 2: Prove app isolation, recorded parity and security review in one final gate</name>
<files>../fonoteka.go/parity/genre_integration_test.go, ../fonoteka.go/parity/genre_security_test.go, ../fonoteka.go/parity/parity_contract_test.go, scripts/check-phase3.sh, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md</files>
<read_first>../fonoteka.go/parity/genre_integration_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/parity_contract_test.go, ../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/synthetic_test.go, scripts/check-phase2.sh, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md</read_first>
<action>Complete independent app test cases for owner, editor and foreign albums with positive counts; active-context fallback; `non_empty=0|1|invalid`; exact seeded genre order under database ICU `pl-PL`; empty `data:[]`; JWT 401 variants, locked-user 423, and CORS preflight. Add a corpus contract that checks 154 recorded, 1 real replay pass, 153 pending, 0 false passes and a deliberate mismatch failure against the unmodified fixture. Create `scripts/check-phase3.sh` to run root and app `go vet ./...`, `go test ./...`, `go test -race ./...`, the focused genres parity subtest, corpus audit, and Phase 2 harness regression; it must exit nonzero on any failure and never silently skip Docker. Perform the roadmap's security review of token verification, cross-plugin guard lookup, migration isolation, query tenant boundaries, and secret handling. Record each T-03 threat, source location, test evidence, finding and disposition in `03-SECURITY-REVIEW.md`; resolve high-severity findings before marking the gate green. Fill `03-VALIDATION.md` with actual task IDs, commands and observed results; set `nyquist_compliant: true` only after the full gate passes. Keep the PHP fixture, generic `tide` code and other 153 route statuses unchanged.</action>
<verify><automated>bash scripts/check-phase3.sh</automated></verify>
<acceptance_criteria>
- `bash scripts/check-phase3.sh` exits 0 with root and app vet/test/race green and one real ported parity pass.
- The corpus report is 154 recorded, 1 passing, 153 pending, 0 failing, 0 unrecorded; a deliberate response mutation fails a contract test.
- `03-SECURITY-REVIEW.md` maps all high-severity threats to passing tests or a resolved finding; no open high-severity JWT or cross-tenant issue remains.
- `03-VALIDATION.md` records observed evidence and only then has `nyquist_compliant: true`.
</acceptance_criteria>
<done>One command proves the first real Go route's parity and its security boundaries, with evidence ready for phase verification.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Test fixture and adversarial HTTP inputs → running app | Tests must exercise real routing, auth and data boundaries. |
| Security review → phase acceptance | Unresolved high-severity findings cannot be hidden by a green happy-path fixture. |
## STRIDE Threat Register
| Threat ID | Category | Severity | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-02 | Elevation of privilege | high | named middleware | mitigate | Missing-name and unauthenticated-route tests plus source review. |
| T-03-03 | Spoofing | high | JWT guard | mitigate | Full malformed/forged/expired/unknown-user matrix and secret handling review. |
| T-03-04 | Information disclosure | high | aggregate query | mitigate | Cross-tenant owner/editor/foreign album tests and query review. |
| T-03-06 | Tampering | high | parity acceptance | mitigate | Real replay, honest pass counter, deliberate mismatch test. |
</threat_model>
<verification>
Run the repeatable Phase 3 script from the framework root after both task commits. Inspect its output and the security review/validation artifacts before recording success.
</verification>
<success_criteria>
All four Phase 3 roadmap criteria have direct passing evidence, the recorded PHP fixture is unchanged, and no high-severity security issue remains open.
</success_criteria>
<output>
Create `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-04-SUMMARY.md` after completion.
</output>

View File

@@ -23,7 +23,7 @@ Not in this phase: any other route (including `POST genres`), register/login/ref
- **D-03:** Active-collection resolution lives in the `golem15.fonoteka` plugin as a small `ActiveCollection` service porting only the default path of `ActiveCollectionResolver.php` (stored active collection, else the user's own). Handlers call it the way PHP controllers call `context()`. It is not a framework middleware; the pipeline's org-context stage stays a generic slot. Phase 12 extends the service with switching and editor membership.
- **D-04:** The handler is ported whole: `?non_empty` is validated (`nullable`, in `0|1`), bad values return PHP's 422 envelope (`{"error":"Validation failed","errors":{...}}`), and `non_empty=1` filters to genres with a positive count. No new fixtures are recorded for these; they are covered by Go tests with the expected bodies read from the PHP source.
- **D-05:** The non-zero count path and access scoping are proven by a testcontainers integration test in `fonoteka.go` alongside the parity test: insert albums for alice in her active collection, albums in a collection she cannot access, assert per-genre counts, `non_empty=1` filtering and that foreign albums are not counted. The parity fixture stays exactly as recorded; the PHP recorder is not re-run in this phase.
- **D-06:** The Postgres equivalent of `PolishOrder` is a research item with a user confirmation at plan review. The researcher reads `PolishOrder.php` and compares an ICU `pl` collation applied in the query, a column-level collation, and a database-level locale (including what testcontainers must match). Whatever is chosen lands as a reusable `lagoon` helper called at the same call site as PHP's `PolishOrder::apply`, because every later list endpoint needs it.
- **D-06:** The Postgres equivalent of `PolishOrder` is a database-level Polish ICU locale, confirmed by the user at plan review on 2026-09-17 after comparing query-level, column-level, and database-level options. Create the application database with ICU locale `pl-PL` before migrations; configure testcontainers to create the same kind of database and fail startup if the connected database uses a different provider or locale. A reusable `lagoon` ordering helper is still called at the same handler call site as PHP's `PolishOrder::apply`, validating the allowed column and direction while relying on the database default. Every later list endpoint can use that helper.
### JWT guard
- **D-07:** Verification is real; only issuing is throwaway. The guard validates HS256 with the algorithm pinned (no `alg` from the token header, `none` rejected), checks `exp` and `sub`, loads the user row by `sub` from the minimal `users` table, and places the user in the request context. A token for a missing user is rejected. Phase 7 replaces how tokens are minted and keeps this verifier.

View File

@@ -0,0 +1,34 @@
# Phase 3 — Codebase Pattern Map
**Mapped:** 2026-09-17
**Scope:** framework mechanisms and the first Fonoteka app route
| New or modified area | Closest existing analog | Pattern to preserve |
|---|---|---|
| `lagoon` connection and service publishing | `backpack/app.go`, `backpack/services.go` | `backpack.App` owns per-instance config/services; publish `*sql.DB` and `*gorm.DB` there, avoid request globals |
| `pact.HasMigrations`, `HasRoutes`, `HasMiddleware`, `HasModels` | `pact/capabilities.go` | Small optional interfaces type asserted by the kernel; no framework import of app plugin packages |
| Plugin migration/route assembly | `party/registry.go` | `Activate` topologically orders `Requires()`, runs all `Register` before any `Boot`; use that order for migrations and fail on missing dependencies/names |
| Named route builder and HTTP server | `bonfire/command.go`, `cmd/summer/main.go`, `examples/hello/main.go` | Generic command values on `bonfire`; app generated entry point composes plugin capabilities; handlers remain standard `http.Handler` |
| Request context | `towel/context.go` | Unexported key type plus exported getter/setter functions, no package-level current user |
| Configuration | `compass/config.go`, `compass/env.go`, `examples/hello/plugins/base/config/config.yaml` | Plugin namespace config merges before env override; missing auth secret fails boot |
| App workspace/plugins | `examples/hello/go.mod`, `examples/hello/summer.yaml`, `examples/hello/plugins.gen.go`; `../fonoteka.go/go.mod` | Separate modules and generated blank imports; app requires framework by module path with local replace |
| App parity seam | `../fonoteka.go/parity/parity_test.go`, `../fonoteka.go/parity/synthetic_test.go` | `newTarget` returns a handler, `seedHooks` maps names to trusted Go functions, corpus status marks pending/ported; `tide` stays generic |
| Genre contract | PHP `GenreApiController.php`, `ActiveCollectionResolver.php`, `PolishOrder.php`, `SerializesFonoteka.php` | Port exact query and DTO; use local PHP source as behavioral oracle and fixture as recorded acceptance test |
## Reusable Signatures and Boundaries
`party.Plugin` requires `ID() string`, `Requires() []string`, `Register(*backpack.App) error`, and `Boot(*backpack.App) error`. `party.Activate` returns plugins in dependency order after full register/boot completion. Optional capability methods should be declared in `pact` and discovered by type assertion at assembly time, following `HasConfig` and `HasCommands`. Avoid adding app-specific identifiers or imports to `party`, `pact`, `surf`, `lagoon`, or `bouncer`.
`backpack.App` has `Config`, `Services`, and `Events`. Its generic `Publish` and `Lookup` wrappers already supply per-app services. `towel` uses context accessor functions; follow that for authenticated identity, locale, and organization slots.
The `bonfire.Command` value carries `Name`, `Description`, `Flags`, `Args`, and `Run(context.Context, bonfire.Input, bonfire.Output) error`. New `serve`, `migrate`, `migrate:rollback`, and `migrate:status` commands should reuse this command kernel. The existing app parity test already starts Postgres and calls `newTarget(t, db)`; use that exact seam for the real app.
## Cross-Repo Ownership
- `summercms.go`: `lagoon`, `surf`, `bouncer`, capability interfaces, generic CLI and tests, hello typed-route example. No Płytarium models or route names.
- `../fonoteka.go`: user and Fonoteka plugins, migrations, domain query, HTTP handler, generated app binary, parity seed hook/manifest and app tests.
- `/media/nvme/dev/golem15/fonoteka`: read-only PHP reference. The source contract is not edited in Phase 3.
## Security Review Targets
The JWT guard and middleware resolver are load bearing. Plans must provide a threat model for token verification and route authorization, plus an execution-time security review before declaring the slice ready. The final test plan exercises each high severity threat's mitigation.

View File

@@ -0,0 +1,111 @@
# Phase 3: First vertical slice — genres end to end — Research
**Researched:** 2026-09-17
**Domain:** Postgres migrations, plugin HTTP routing, JWT verification, PHP response parity
**Confidence:** High on local contracts and documented APIs; medium on exact Polish collation equivalence
## User Constraints
The locked decisions are D-01–D-20 in [03-CONTEXT.md](03-CONTEXT.md). The planner and executor must read that file in full. The following are the constraints most likely to be lost during implementation:
- D-01–D-06: port the PHP aggregate query, including active collection, tenant scoped counts, `non_empty`, and Polish ordering. The zero count fixture cannot prove the query; a Postgres integration test must.
- D-07–D-11: `golem15.user` owns the real HS256 verifier and minimal user, with `exp`, `sub`, database lookup, fail closed secret config, and PHP compatible 401 behavior. Only token *issuing* is temporary.
- D-12–D-15: `surf` must preserve the named seven stage middleware order, cross plugin middleware lookup, Laravel shaped group builder, and typed/constraint params on stdlib ServeMux.
- D-16–D-20: immutable squashed migrations, 15 ordered genre seeds, plugin isolated migration histories and rollback, and an app owned temporary parity hook. The recorded fixture and generic `tide` code remain unchanged.
- Discretion and deferrals remain exactly as stated in 03-CONTEXT.md. In particular, River's listener pool, full auth groups, full models, token issuing, and additional PHP fixtures belong to later phases.
## Summary
The first executable slice should boot the actual Fonoteka app, migrate a fresh Postgres database, serve one JWT protected route, and replay the recorded `get_genres_jwt.yaml` fixture through the existing harness. The framework owns reusable connection, migration, routing, and verifier mechanisms; the app owns schema, user/genre models, access policy, middleware registrations, and response DTO. [VERIFIED: local `pact/capabilities.go`, `party/registry.go`, `backpack/app.go`, PHP source, parity test]
The most consequential hidden dependency is the PHP access path. The genre handler reads `golem15_fonoteka_user_collection_contexts` for the selected collection and `golem15_fonoteka_collection_editors` when checking access, in addition to users, collections, albums, and genres. The minimal schema therefore needs those two small relation tables, or a clearly equivalent representation that preserves owner/editor access and stored context. The existing `Collection::accessibleBy` groups owner OR editor before later filters; Go must preserve that grouping. [VERIFIED: local `ActiveCollectionResolver.php`, `Collection.php`, `Album.php`]
**Primary recommendation:** use one `*sql.DB` opened through pgx stdlib and handed to GORM; give each plugin a gormigrate instance with its own history table; create the application database with Polish ICU as its default locale; prove the final SQL and HTTP behavior in testcontainers before marking the route ported. The user confirmed database-level locale for D-06 on 2026-09-17.
## Architectural Responsibility Map
| Capability | Framework | Application |
|---|---|---|
| Connection and migration runner | `lagoon` opens/publishes one `*sql.DB` plus `*gorm.DB`, topologically runs plugin sets, implements CLI | User and Fonoteka plugins supply immutable `[]*gormigrate.Migration` sets and exact table DDL |
| Routing and middleware | `surf` compiles group/route declarations to ServeMux, resolves names and stages, handles generic recover/CORS/locale/org/rate seams | User registers `jwt.auth`; Fonoteka registers `inv.must-change-password` and genres group |
| Authentication | `bouncer` verifies pinned HS256 and stores authenticated identity in request context | User plugin reads user by subject and supplies secret from plugin config |
| Domain query | Reusable allow listed Polish ordering helper | Active collection resolver, access scoped album count query, genre DTO and JSON envelope |
| Parity | Existing `tide` unchanged | `fonoteka.go/parity` supplies real handler, temporary seed hook and ported manifest status |
## Standard Stack and Package Legitimacy
| Module | Planned version | Evidence | Audit |
|---|---:|---|---|
| `gorm.io/gorm` | v1.31.2 | `go list -m -versions`; [official GORM connection guide](https://gorm.io/docs/connecting_to_the_database.html) | slopcheck OK |
| `gorm.io/driver/postgres` | v1.6.3 | `go list -m -versions`; [official GORM driver guide](https://gorm.io/docs/connecting_to_the_database.html) | slopcheck SLOP based on recent registry timestamp; **false positive against the project's locked stack and official module path**. Verify resolved `go.mod`/`go.sum` before install. |
| `github.com/jackc/pgx/v5/stdlib` | v5.10.0 | already in app module; [upstream stdlib source](https://github.com/jackc/pgx/blob/master/stdlib/sql.go) | slopcheck SUS based on recent version age; official upstream confirms path |
| `github.com/go-gormigrate/gormigrate/v2` | v2.1.7 | `go list -m -versions`; [upstream README](https://github.com/go-gormigrate/gormigrate) | slopcheck SLOP based on recent registry timestamp; **false positive against the project's locked decision and official source**. Verify resolved module before install. |
| `github.com/golang-jwt/jwt/v5` | v5.3.1 | `go list -m -versions`; [official package docs](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) | slopcheck OK |
| `github.com/go-playground/validator/v10` | v10.30.4 | `go list -m -versions`; project STACK.md | slopcheck SUS based on recent version age; only add if the implementation actually uses it for `non_empty` |
`slopcheck scan /tmp/phase03-proposed/go.mod` was run against the six proposed modules. Its age heuristic flagged four recent *versions* despite authoritative upstream paths; the executor must inspect the exact module paths and sums at the dependency commit. No alternate package is warranted: GORM, gormigrate, pgx, JWT and validator are already named in project decisions. The standard library supplies ServeMux, context, SQL pool and JSON. [VERIFIED: local project decisions, registry version query, upstream docs]
## Architecture Patterns
### Connection and migration lifecycle
Open `sql.Open("pgx", dsn)`, ping with the application boot context, configure pool limits, then pass the same handle to `postgres.New(postgres.Config{Conn: sqlDB})` for `gorm.Open`. Publish both handles once on the app service registry; close the SQL pool on shutdown. GORM explicitly supports an existing `*sql.DB` and pgx stdlib documents the `pgx` driver name. This leaves a separate listener pool seam for Phase 11 without creating it now. [CITED: https://gorm.io/docs/connecting_to_the_database.html; https://github.com/jackc/pgx/blob/master/stdlib/sql.go]
Use one gormigrate object per plugin with `Options.TableName` derived from a validated plugin ID, for example `summer_migrations_golem15_user` and `summer_migrations_golem15_fonoteka`; run plugin sets in `party.Activate` dependency order. Its `Migrate()` and `RollbackLast()` APIs provide ordered up/down and isolated history. A status command should read the history table rather than infer state from GORM model structs. Migration IDs must be stable and unique per plugin. For every migration, use explicit `CREATE TABLE`/`ALTER TABLE` SQL or GORM migrator operations inside migration callbacks, never `AutoMigrate`. [CITED: https://github.com/go-gormigrate/gormigrate; VERIFIED: local `party/registry.go`]
The first app migration set needs full `golem15_fonoteka_genres`; minimal `users`, `golem15_fonoteka_collections`, `golem15_fonoteka_albums`, `golem15_fonoteka_collection_editors`, and `golem15_fonoteka_user_collection_contexts` columns sufficient for owner/editor access and active context. The user plugin owns users. Fonoteka owns the other tables and the 15 genre rows. Put the genre seed in a separate idempotent data migration after genre DDL so `RollbackLast` can remove only the seed while preserving the table. Record source PHP update filenames as comments. [VERIFIED: local PHP models, resolver, seed migration]
### HTTP and auth lifecycle
Compile `r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), ...)` to a `GET /_fonoteka/api/v1/genres` ServeMux registration. `Request.PathValue` supplies wildcard values; typed integer, regex and enum constraints can validate after ServeMux matching and return 404 for malformed IDs. Reject duplicate routes and missing middleware during boot with the plugin and name in the error. ServeMux documents method/path patterns and path wildcards. [CITED: https://pkg.go.dev/net/http]
Wrap the handler in the locked stage order: recover → CORS → locale → auth group → must change password → org context → rate limit → handler. Group and route declarations attach named middleware to their stage; the framework must not depend on the user or Fonoteka plugin packages. For the first slice, generic CORS/locale/org/rate behavior is intentionally small, while JWT and 423 password lock are real. Ensure CORS preflight completes before auth and no route bypasses recovery. [VERIFIED: local PHP `routes.php`, `JwtAuthenticate.php`, `RequirePasswordChange.php`, 03-CONTEXT.md]
Use `jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())`; also require a nonempty `sub`, parse it as the user ID, load that user, and reject missing users. Pinning algorithms through `WithValidMethods` is recommended by the library; `exp` is optional without `WithExpirationRequired`. The secret must be nonempty at boot and sourced from `golem15.user` config. Tests should cover `alg:none`, a different HMAC algorithm, bad signature, expired token, missing subject and unknown subject. [CITED: https://pkg.go.dev/github.com/golang-jwt/jwt/v5; VERIFIED: local PHP `config/jwt.php`]
### Genre query and response
Implement an app service equivalent to the default branch of `ActiveCollectionResolver::resolve`: use stored context if still accessible; otherwise choose the first accessible collection, limited to real `kind='collection'`. The PHP code can also provision a collection if none exists; Phase 3 context narrows this to the default path and the seed hook supplies Alice's collection. Preserve the owner/editor predicate, with `collection_id = active.id` as an additional AND. Then left join grouped album counts to all 15 genres, filter positive counts for `non_empty=1`, and select only `id`, `name`, `slug`, `COALESCE(album_count,0)`. A dedicated DTO emits `{"data":[{"id":...,"name":...,"slug":...,"album_count":...}]}` with integer IDs/counts and an initialized empty slice. Set `Cache-Control: no-cache, private` and `Content-Type: application/json` as recorded. [VERIFIED: local PHP handler/serializer and `get_genres_jwt.yaml`]
PHP's `PolishOrder::apply` uses MariaDB `utf8mb4_polish_ci` on an allow listed name column. The user chose a Polish ICU **database default** for Postgres. Create a fresh application database with `CREATE DATABASE ... TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`, or initialize a fresh dedicated Postgres cluster with `POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8"`. Database locale is provisioned before migrations; a migration cannot change an existing database's default. Pin the Phase 3 test image to `postgres:16-alpine`, which the app parity test already uses, and pass those init arguments through `testcontainers.WithEnv`. At startup, check `pg_database.datlocprovider = 'i'` and `daticulocale = 'pl-PL'` for `current_database()` (Postgres 16 catalog), then fail with a setup message if either differs. The reusable `lagoon` helper still validates the qualified column and `asc|desc`, and emits ordinary `ORDER BY` at the PHP helper's call site so the database default applies. The fixture's complete 15 row order and a future accented/punctuation case must be tested because ICU and MariaDB collation semantics can differ. [CITED: https://www.postgresql.org/docs/16/sql-createdatabase.html; https://www.postgresql.org/docs/16/app-initdb.html; https://hub.docker.com/_/postgres; https://golang.testcontainers.org/modules/postgres/; VERIFIED: local `PolishOrder.php`, `../fonoteka.go/parity/synthetic_test.go`]
## Don't Hand-Roll
| Problem | Use | Reason |
|---|---|---|
| JWT parsing/signature/claim checks | `golang-jwt/jwt/v5` parser options | Avoid algorithm confusion and ad hoc expiry parsing. |
| Migration bookkeeping/rollback | gormigrate `Options.TableName`, `Migrate`, `RollbackLast` | Keep each plugin's history isolated. |
| HTTP path dispatch | Go `http.ServeMux` | Native method/path patterns and wildcard values already exist. |
| JSON parity | existing `tide.ReplayFlow` | Phase 2 already owns fixture semantics and mismatch reports. |
## Common Pitfalls
1. **A green zero count fixture hides a wrong query.** Seed nonzero owned/editor/foreign albums in the app integration test, assert exact counts, `non_empty=1`, and no foreign leak.
2. **A route's `jwt.auth` can be bypassed if middleware names are ignored.** Missing names fail boot; add a route registration inspection and unauthenticated HTTP test.
3. **`sub` or `exp` can be absent even with a valid signature.** Require both; verify user existence; use one 401 body class matching PHP, with no token or secret in logs.
4. **Schema can accidentally widen or drift.** Keep initial migrations immutable and explicit; test up/down and per plugin rollback with Postgres; reject `AutoMigrate` in review.
5. **Nested module tests can be missed.** Root `go test ./...` does not cover `../fonoteka.go`; run vet/test in both modules and the app parity subtest.
6. **The fixture has captured ID collisions.** In the app seed hook, set `id:genre`, `id:token`, and `id:wishlist-album` to the recorded genre IDs while minting test only `jwt:alice`; do not edit the fixture or scrubber.
7. **MVP sequencing can become a layer cake.** The first implementation plan should produce a database backed app route with a runnable request, then refine auth, count fidelity, and parity. The dedicated final plan carries comprehensive unit/security tests as required by CLAUDE.md.
## Validation Architecture
- **Fast feedback:** after each task commit run `go vet ./... && go test ./...` in the framework, and the same in `../fonoteka.go` once app code exists. Preserve the Phase 1 and Phase 2 checks.
- **Postgres integration:** use the existing app testcontainers setup. Test two plugins' migrations in dependency order, independent history tables, both up/down, idempotent 15 genre seed, shared `*sql.DB` identity, active collection and access scoped counts. Do not replace the existing corpus test's synthetic handler until the real app handler boots.
- **HTTP and security:** `httptest` should assert all seven middleware stages in order; `jwt.auth` status/body for missing, malformed, expired, bad signature and unknown user; empty secret boot failure; 423 gate after auth; CORS preflight; missing middleware boot failure; typed ID/constraint 404 in `surf` and `examples/hello`.
- **Parity acceptance:** run only `GET /_fonoteka/api/v1/genres jwt` against the real app initially, then ensure corpus coverage reports exactly one ported/pass and 153 pending. Replay uses the unmodified fixture and manifest seed hook. The fixture's JSON comparison ignores object key order but catches body token types, array order, headers and missing fields; do not weaken it.
- **Final gate:** the last plan adds comprehensive unit and security focused tests, runs root and app `go vet`, `go test`, `go test -race`, and the Phase 2 regression check. If Docker is unavailable, app test failure is explicit, not skipped.
## Open Decision for Plan Review
**D-06 Polish order:** the user confirmed database-level ICU `pl-PL` on 2026-09-17. Plans must include database provisioning instructions, testcontainers `POSTGRES_INITDB_ARGS`, a startup provider/locale check, and an allow listed `lagoon` ordering helper called by the genre handler. Test the exact fixture order and fail visibly if ICU is unavailable or differs. Confidence on MariaDB equivalence remains medium until the experiment passes.
## Sources
- Local: 03-CONTEXT.md; `pact/capabilities.go`, `party/registry.go`, `backpack/app.go`, `tide/*`; `../fonoteka.go/parity/parity_test.go`, manifest and recorded genres fixture; the PHP controller, model, resolver, middleware, serializer, seed migration and `PolishOrder.php` named in CONTEXT.md.
- [GORM existing connection](https://gorm.io/docs/connecting_to_the_database.html), [pgx stdlib](https://github.com/jackc/pgx/blob/master/stdlib/sql.go), [gormigrate](https://github.com/go-gormigrate/gormigrate), [Go ServeMux](https://pkg.go.dev/net/http), [JWT parser options](https://pkg.go.dev/github.com/golang-jwt/jwt/v5), [PostgreSQL collations](https://www.postgresql.org/docs/current/collation.html).
---
*Phase: 03-first-vertical-slice-genres-end-to-end*
*Ready for planning: D-06 and four-plan count confirmed by user on 2026-09-17*

View File

@@ -0,0 +1,73 @@
---
phase: 03
slug: first-vertical-slice-genres-end-to-end
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-09-17
---
# Phase 3 — Validation Strategy
## Test Infrastructure
| Property | Value |
|---|---|
| Framework | Go 1.27 `testing`, `httptest`; existing `../fonoteka.go/parity` testcontainers Postgres suite |
| Config | Root `go.mod` plus app `../fonoteka.go/go.mod`; existing `go.work` only covers the framework and hello example |
| Quick run | `go vet ./... && go test ./...` in each of `summercms.go` and `../fonoteka.go` |
| Full suite | Root and app vet/test/race, focused real genres parity subtest, Phase 2 corpus contract and regression script |
| Feedback latency | Fast loop should stay within the existing warmed Go test times; Postgres and PHP replay run at wave/final gates |
## Sampling Rate
- After every implementation task commit: root `go vet ./... && go test ./...`; after app files exist, run the same commands in `../fonoteka.go`.
- After each plan wave: run the focused Postgres route/genre test and the ported parity subtest in the app, plus the quick checks.
- Before Phase 3 verification: run root and app `go test -race ./...`, all ported corpus routes, migration up/down, and Phase 2 regression checks.
- Docker unavailable is a failed app integration gate, following Phase 2 `TestMain` behavior; `testing.Short` remains a fast local option only.
## Per-Task Verification Map
The user confirmed four plans on 2026-09-17. Every task has an automated `<verify>` command and an observable acceptance criterion. These rows are planned checks; results are filled during execution.
| Task ID | Wave | Requirement | Threat | Automated evidence | Status |
|---|---:|---|---|---|---|
| 03-01-01 | 1 | DATA-01, DATA-02 | T-03-01, T-03-SC | Root/app vet/test; Postgres 16 ICU migration smoke, 15 seeds, separate history | Pending |
| 03-01-02 | 1 | HTTP-01, HTTP-02, QA-04 | T-03-02, T-03-03 | Root/app vet/test; persisted-user JWT request reaches real genre row | Pending |
| 03-02-01 | 2 | QA-04 | T-03-04 | Root/app vet/test; focused TestGenre nonzero/foreign/editor counts | Pending |
| 03-02-02 | 2 | QA-04 | T-03-05 | Root/app vet/test; focused TestGenre `non_empty`, 422, 15-name order | Pending |
| 03-03-01 | 3 | DATA-02, HTTP-01 | T-03-01, T-03-07 | Root/app vet/test; rollback isolation and hello typed route | Pending |
| 03-03-02 | 3 | QA-04 | T-03-06 | Root/app vet/test; TestParityCorpus one real pass and 153 pending | Pending |
| 03-04-01 | 4 | DATA-01, DATA-02, HTTP-01, HTTP-02 | T-03-01 to T-03-03 | Root vet/test/race and app vet/test; adversarial auth/routing tests | Pending |
| 03-04-02 | 4 | QA-04, HTTP-02 | T-03-04 to T-03-07 | `bash scripts/check-phase3.sh`; app test/race, PHP harness regression, security review | Pending |
| Capability | Requirement | Threat | Test and evidence |
|---|---|---|---|
| Shared pgx/GORM connection and plugin migrations | DATA-01, DATA-02 | T-03-01 schema drift | Testcontainers creates all minimal tables and 15 ordered genres; app and framework observe one `*sql.DB`; two plugin histories survive independent up/down/rollback; no `AutoMigrate` call |
| Named route and middleware pipeline | HTTP-01, HTTP-02 | T-03-02 auth bypass | `httptest` records seven stage order, preflight behavior, missing-name boot failure, typed malformed/unknown ID 404, JWT/must-change-password placement |
| JWT guard | HTTP-02, QA-04 | T-03-03 token forgery | Missing/malformed/expired/wrong algorithm/bad signature/missing `sub`/unknown user yield PHP shaped 401; empty secret fails boot; valid user reaches handler |
| Active collection and genre aggregate | QA-04 | T-03-04 cross-tenant data leak | Postgres inserts owned/editor/foreign albums; exact positive counts and `non_empty=1` set; foreign count remains zero; invalid query yields 422 envelope; empty list is `[]` |
| Polish order | QA-04 | T-03-05 sort drift | Testcontainers initializes Postgres 16 with ICU `pl-PL`; startup rejects a libc or wrong-locale database; ordinary ORDER BY returns all 15 names in fixture order; accented/punctuation edge case documented as a later regression fixture risk |
| Recorded parity | QA-04 | T-03-06 false green | Real `newTarget` and temporary seed hook replay unmodified fixture; corpus reports one ported/pass and 153 pending; deliberate response mutation fails diff |
## Wave 0 Requirements
- Existing `../fonoteka.go/parity/TestMain` already starts a Postgres container and the recorded fixture exists. The first implementation slice adds the real app boot and a focused route smoke test before moving `newTarget`.
- Test helper for applying both plugin migration sets and seeding Alice's active collection belongs in the app test package, and must not be copied into `tide`.
- No additional test framework or watch mode is required.
## Manual-Only Verifications
| Behavior | Requirement | Why manual | Test instructions |
|---|---|---|---|
| Database provisioning | QA-04 | Production Postgres lies outside the test suite | Use a fresh database created with `TEMPLATE template0`, `LOCALE_PROVIDER icu`, `ICU_LOCALE 'pl-PL'`, and UTF8 encoding; run the startup locale check before migrations. The user confirmed database-level locale on 2026-09-17. |
## Validation Sign-Off
- [ ] Plan IDs and per-task commands filled after plan-count checkpoint.
- [ ] Every task has automated feedback; no three consecutive tasks without it.
- [ ] Security threat model appears in each plan and the final plan tests its high severity mitigations.
- [ ] Root and app quick checks, race checks, Postgres integration, and recorded parity are green.
- [ ] Mark `nyquist_compliant: true` only after implementation evidence exists.
**Approval:** pending execution.