# 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*