17 KiB
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. 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.userowns the real HS256 verifier and minimal user, withexp,sub, database lookup, fail closed secret config, and PHP compatible 401 behavior. Only token issuing is temporary. - D-12–D-15:
surfmust 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
tidecode 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 |
slopcheck OK |
gorm.io/driver/postgres |
v1.6.3 | go list -m -versions; official GORM driver guide |
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 | 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 |
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 |
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
- 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. - A route's
jwt.authcan be bypassed if middleware names are ignored. Missing names fail boot; add a route registration inspection and unauthenticated HTTP test. suborexpcan 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.- Schema can accidentally widen or drift. Keep initial migrations immutable and explicit; test up/down and per plugin rollback with Postgres; reject
AutoMigratein review. - 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. - The fixture has captured ID collisions. In the app seed hook, set
id:genre,id:token, andid:wishlist-albumto the recorded genre IDs while minting test onlyjwt:alice; do not edit the fixture or scrubber. - 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.goonce 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.DBidentity, 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:
httptestshould assert all seven middleware stages in order;jwt.authstatus/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 insurfandexamples/hello. - Parity acceptance: run only
GET /_fonoteka/api/v1/genres jwtagainst 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 andPolishOrder.phpnamed in CONTEXT.md. - GORM existing connection, pgx stdlib, gormigrate, Go ServeMux, JWT parser options, PostgreSQL collations.
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