20 KiB
Phase 3: First vertical slice — genres end to end - Context
Gathered: 2026-09-17 Status: Ready for planning
## Phase BoundaryPhase 3 makes one real route, GET /_fonoteka/api/v1/genres, pass the Phase 2 parity diff through every kernel layer at once: layered config, one shared *sql.DB (pgx stdlib) under GORM, per-plugin gormigrate sets with up and down, plugin route registration on net/http ServeMux, the seven-stage named middleware pipeline, a JWT guard, a GORM query and the JSON response. Requirements DATA-01, DATA-02, HTTP-01, HTTP-02, QA-04. Roadmap mode: mvp.
Two repos. summercms.go gains the framework packages the slice pulls in: surf (routing, named middleware, pipeline, typed params), lagoon (DB connection, migration runner, query helpers), bouncer (token verifier and auth context only), the HasRoutes/HasMigrations/HasModels/HasMiddleware-style capability interfaces in pact, and the runtime commands (serve, migrate:*) on the bonfire kernel. It still knows nothing about Płytarium. fonoteka.go becomes a real app: a go.work workspace with a stub golem15.user plugin, the first cut of the golem15.fonoteka plugin, the generated app binary, and the parity test wired to the real handler.
Not in this phase: any other route (including POST genres), register/login/refresh and token issuing (Phase 7), the guard registry and the personal-token and public auth groups, named rate-limit buckets and full CORS/locale behavior (Phase 6), the full column set, casts and mass-assignment discipline of the 25 models (Phase 5), River, Centrifugo, Typesense, OpenAPI generation. The JWT guard is on the request path, so the phase is security-load-bearing: the security-review agent runs on it even though token issuing is throwaway.
Handler fidelity
- D-01: The Go handler ports the real PHP query, not a shortcut: resolve the authenticated user and the active collection, build the accessible-albums subquery scoped to that collection, left-join the per-genre
COUNT(*), selectid,name,slug,COALESCE(album_count, 0), order by name with the Polish ordering, serialize as{"data":[...]}. The fixture showsalbum_count: 0everywhere, so the diff alone would accept a fake; the port is faithful anyway because proving GORM can express this join is the point of the slice. - D-02: Only the tables and columns that query touches exist in this phase:
golem15_fonoteka_genresin full, and minimalusers, collections and albums tables (ids, ownership and membership columns the access scope reads,genre_id,collection_id, the user's active-collection pointer, the must-change-password flag). Table names match PHP exactly. Phase 5 widens them (see D-16). - D-03: Active-collection resolution lives in the
golem15.fonotekaplugin as a smallActiveCollectionservice porting only the default path ofActiveCollectionResolver.php(stored active collection, else the user's own). Handlers call it the way PHP controllers callcontext(). 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_emptyis validated (nullable, in0|1), bad values return PHP's 422 envelope ({"error":"Validation failed","errors":{...}}), andnon_empty=1filters 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.goalongside the parity test: insert albums for alice in her active collection, albums in a collection she cannot access, assert per-genre counts,non_empty=1filtering 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
PolishOrderis 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 localepl-PLbefore migrations; configure testcontainers to create the same kind of database and fail startup if the connected database uses a different provider or locale. A reusablelagoonordering helper is still called at the same handler call site as PHP'sPolishOrder::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
algfrom the token header,nonerejected), checksexpandsub, loads the user row bysubfrom the minimaluserstable, 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. - D-08: A stub
golem15.userplugin exists infonoteka.gofrom this phase, mirroring PHP ownership: it owns the minimalUsermodel and its migration and registers the named middlewarejwt.auth.golem15.fonotekadeclaresRequires()on it and referencesjwt.authby name. Phase 7 fills this plugin in rather than relocating code. - D-09: Framework
bouncerships only: the HS256 verification helper (golang-jwt/jwt v5), the authenticated-user context key with exported accessor functions (unexported struct key type, per Phase 1), and a smallUserProvider-style interface the user plugin implements. No guard registry, no personal-token or public groups, no token issuing API: Phase 6 and Phase 7 own those. - D-10: Missing, malformed, expired, bad-signature and unknown-user tokens return the same status and JSON body
JwtAuthenticate.phpproduces. These are asserted in Go tests against strings taken from the PHP source; no new fixtures are recorded. - D-11: (Not asked in discussion; recorded as a security default that follows from D-07 and Phase 1 D-10.) The JWT secret comes from config (a
golem15.usernamespace key, overridable by aSUMMER_…env var per Phase 1 D-06 and D-08). An empty or missing secret fails boot; it never falls back to a default. Tests use a fixed test-only secret.
Middleware pipeline and routing API
- D-12: All seven stages exist as real named middleware in the fixed order (recover, CORS, locale, auth group, must-change-password, org context, rate limit, handler), each with the smallest honest behavior: recover turns panics into a 500 without leaking internals; CORS answers preflight and sets headers from config; locale reads
Accept-Languageinto context; org context is a pass-through slot; rate limit is a no-op limiter behind the interface Phase 6 will implement. Order and wiring are proven now; depth comes in Phase 6. Do not pull Phase 6 behavior forward. - D-13: The must-change-password gate is real and owned where PHP owns it:
golem15.fonotekaregistersinv.must-change-password(port ofRequirePasswordChange.php) and attaches it by name afterjwt.auth. It reads the flag on the minimal user and returns PHP's 423 body, Go-tested. This is the slice's proof of HTTP-02: one plugin referencing another plugin's middleware by name. - D-14: Routes are declared with a Laravel-like group builder that compiles to stdlib ServeMux patterns, so porting
routes.phpis line by line. Target feel:r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "inv.must-change-password"), func(g surf.Group) { g.Get("genres", h.Index) }). Handlers remain plainhttp.HandlerFunc/http.Handlerso swag annotations work later. Group-level and per-route middleware are both by name, matching PHP's->middleware('throttle:10,1')call sites. - D-15: The typed-param helper ships now even though genres has no path params: an integer param accessor with the rule that unknown and malformed ids both yield 404 (HTTP-01), plus the regex/enum constraint shape PHP's
->where()uses. It is exercised bysurf's own tests and a route inexamples/hello, so success criterion 2's "typed params" clause is met and Phase 12 does not invent it.
Migrations and seed data
- D-16: Go migrations are squashed to final state per table, not a replay of PHP's history: Postgres starts empty, so
golem15_fonoteka_genresis created directly with noitem_categoriesdetour. Each migration carries a comment naming the PHP update files it folds. Production data arrives through a cutover import (Phase 15), not through migrations. - D-17: Phase 3 migrations are permanent and never edited. The minimal users, collections and albums tables are widened in Phase 5 by appended ALTER migrations. "Never edit a shipped migration" applies from the first migration.
- D-18: The 15 genres are a data migration in the
golem15.fonotekaset, as in PHP (v1.1.0/seed_genre_and_various_artist_taxonomy.php): idempotent by slug, inserted in the PHP order so ids come out 1–15, with a Rollback that removes them. A fresh Go database has genres without any seeder command. - D-19: Each plugin ships its own ordered
[]*gormigrate.Migrationset through a capability interface; sets run in plugin dependency order (golem15.userbeforegolem15.fonoteka) with per-plugin version tracking, so rolling back one plugin never touches another's history (ARCHITECTURE.md Anti-Pattern 4).AutoMigrateis not used anywhere, tests included. Up and down are both exercised in tests for each set. - D-20: The temporary
genresseed hook (Phase 2 D-10) infonoteka.go/parityinserts alice and her collection through GORM, mints her token with the test secret intojwt:alice, and sets the colliding id variables (id:genre,id:token,id:wishlist-album) to the values the recorded fixture expects. The fixture stays byte-for-byte as recorded andtide's scrubber is not changed in this phase. The hook is declared temporary in the manifest with the routes that will replace it (register/login,POST genres), and the genres route flips frompendingtoported.newTargetreturns the real app handler instead of the synthetic one.
Claude's Discretion
- Package internals and file layout of
surf,lagoonandbouncer; exact capability interface names inpact(follow ARCHITECTURE.md Pattern 1 and the Phase 1 naming). - DB config section shape (
database.*keys, pool sizes) and how the shared*sql.DBis published on thebackpackcontainer. The separate pgx pool for River's listener (DATA-01's second half) is not created until Phase 11; leave the seam, note it in the plan. - Version-tracking table naming for per-plugin gormigrate state.
- The
serveandmigrate:*command set on thebonfirekernel: at minimumserve,migrate,migrate:rollback,migrate:status, colon-style per Phase 1 D-15. Graceful shutdown on signal. - Behavior when a route references an unregistered middleware name: fail boot with an error naming the plugin and the missing name, consistent with Phase 1 D-10.
- The response DTO for the genre aggregate (a dedicated struct, not the GORM model, per PITFALLS.md Pitfall 3) and
[]notnullfor an empty list (Pitfall 4). - How
fonoteka.gois laid out as a workspace (plugins/golem15/user,plugins/golem15/fonoteka, generatedmain.goandplugins.gen.goviasummer build,summer.yamlmanifest order). - Plan count and split, subject to the repo's plan-count checkpoint and "unit tests are the last plan" rules.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
The PHP contract for this slice
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php—index()(validation, count subquery,PolishOrder,non_empty,serializeGenreAggregate) andcontext(). The handler being ported;store()is out of scope./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.phplines 61–80 and 271 — the two JWT groups (jwt.auth,bindings; andjwt.auth,inv.must-change-password,bindings) and the genres route's place in the gated group. The shape D-14's builder must reproduce./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveCollectionResolver.php— default-path logic to port in D-03./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/PolishOrder.php— the ordering to reproduce on Postgres (D-06)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Genre.phpand theAlbum::accessibleByscope inmodels/Album.php— columns and the access rule that define the minimal tables (D-02)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php—serializeGenreAggregatefield list and types./media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.phpand/media/nvme/dev/golem15/fonoteka/config/jwt.php— claims, algorithm, and the exact 401 bodies (D-07, D-10)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/RequirePasswordChange.phpandPlugin.phparound line 214 — the 423 gate and how it is registered by name (D-13)./media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/version.yaml,updates/v1.0.9/rename_taxonomy_and_notes_columns.php,updates/v1.1.0/seed_genre_and_various_artist_taxonomy.php— the history being squashed and the 15-genre seed with its order (D-16, D-18).
Parity harness seam (Phase 2 output)
../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml— the fixture that must go green, including the{{id:…}}placeholder collisions handled by D-20.../fonoteka.go/parity/manifest.yamlaround line 1513 — the route entry to flip frompendingtoportedand where the temporary seed hook is declared.../fonoteka.go/parity/parity_test.go—newTarget,seedHooks,invokeSeedHook,replayPortedRoute: the app-owned seam this phase fills.../fonoteka.go/parity/capture-rules.yaml— howjwt:aliceis normally captured, which the hook substitutes for..planning/phases/02-api-parity-harness-bootstrap/02-CONTEXT.mdD-10, D-12, D-13, D-15, D-16 — seed hooks, in-process replay host, diff and normalizer rules, pending-never-equals-passing.
Kernel architecture and pitfalls
.planning/research/ARCHITECTURE.md§Pattern 1 (capability interfaces), §Request Flow, §Migration Flow, §Anti-Pattern 1, §Anti-Pattern 3, §Anti-Pattern 4, §Framework vs Application Repo Boundary, §First Vertical Slice. Text that says goose is superseded: gormigrate is authoritative (STATE.md, PROJECT.md Key Decisions)..planning/research/PITFALLS.md§Pitfall 1 (bottom-up kernel: build only what genres pulls), §Pitfall 2 (context not globals), §Pitfall 3 (DTO vs model), §Pitfall 4 (nil slice vs[]), §Pitfall 8 (envelopes and error shapes), §Pitfall 13 (no AutoMigrate), §Pitfall 14 (shared pool), §Security Mistakes, §Pitfall-to-Phase Mapping..planning/research/STACK.md§River + GORM: share one*sql.DB, §Migration tooling, §Version Compatibility (gorm v1.31.2, driver v1.6.3, pgx v5.10.0, golang-jwt v5.3.1, validator v10.30.4, testcontainers v0.44.0).
Project-level decisions
.planning/PROJECT.md§Constraints, §Key Decisions;.planning/REQUIREMENTS.mdDATA-01, DATA-02, HTTP-01, HTTP-02, QA-04;.planning/ROADMAP.mdPhase 3 section..planning/phases/01-framework-kernel-foundation/01-CONTEXT.mdD-01, D-02, D-06, D-08, D-10, D-15, D-17 and §Integration Points — two-binary model, config namespaces and env mapping, fail-fast boot, command naming, and the note thatHasRoutes/HasModels/HasMigrationsare declared for Phase 3 to implement..planning/notes/why-go-not-scala.md— the failure mode this phase exists to avoid.CLAUDE.md(repo root) §GSD workflow rules — lean planning, plan-count checkpoint, unit tests as the last plan, stdlib first,go vetandgo test ./...green at every commit.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
summercms.gokernel from Phase 1:compass(config, namespaces, env overlay),party(plugin registry,Requires()ordering, generated import list,summer build),backpack(container for Register/Boot injection),festival(typed events),bonfire(command kernel both binaries share),pact(currentlyHasCommands,HasConfig,OptionalMessage),towel,examples/hello.tide(Phase 2): the parity library, manifest and fixture schema,summer parity:*commands, replay host and coverage report. Used as is; not modified in this phase.fonoteka.go/parity: recorded 154-route corpus, manifest,parity_test.gowith testcontainers Postgres already starting inTestMain, andscripts/check-phase2.shin the framework repo.
Established Patterns
- Stdlib first. New dependencies this phase are the ones research already names:
gorm.io/gorm,gorm.io/driver/postgres(pgx v5 stdlib underneath),go-gormigrate/gormigrate/v2,golang-jwt/jwt/v5,go-playground/validator/v10. Anything else needs a decision note. No router library: ServeMux only. - Request state only in
context.Contextwith unexported key types and exported accessors; the container is used at Register and Boot only; app and bus are instance fields, not globals. - Fail boot loudly on misconfiguration (missing dependency, missing middleware name, empty JWT secret) rather than warn and continue.
- The framework never imports the app; app-specific logic (active collection, must-change-password, genres) lives in
fonoteka.goplugins.
Integration Points
pactgains the route, middleware, migration and model capability interfaces;partyboot wires them: migrations collected in dependency order, middleware names registered, route groups mounted on one ServeMux behind the pipeline.bonfirekernel gainsserveandmigrate:*, so bothsummerand the generatedfonotekabinary expose them.fonoteka.go/parity/parity_test.gonewTargetswitches from the synthetic handler to the booted app handler; thegenresseed hook is registered inseedHooks.fonoteka.gohas nogo.workor plugins yet: this phase creates the workspace, the two plugins and the generated app entry point.
</code_context>
## Specific Ideas- "Throwaway" applies to token issuing only. The verifier, the 401 bodies and the 423 gate are written to be kept, so the security review has something real to review.
- The routing API should read like
routes.php: a port of the 154 routes should be a line-by-line translation, with middleware referenced by the same names PHP uses (jwt.auth,inv.must-change-password). - The slice must prove cross-plugin wiring, not just one plugin:
golem15.fonotekarequiresgolem15.user, uses its middleware by name, and migrates after it. - A fresh Go database should look like a fresh PHP one: genres present after
migrate, no seeder step. - The recorded fixture is the oracle and is not touched; where it cannot distinguish right from wrong (zero counts), Go integration tests carry the proof.
- Tightening
tide's scrubber so unrelated ids are not replaced by captured-variable placeholders (genre ids 1, 2, 4 became{{id:wishlist-album}},{{id:token}},{{id:genre}}) and re-scrubbing the corpus — a harness fix touching all 154 fixtures; revisit when a second ported route hits the same collision, or before Phase 12. - Recorded fixtures for genres with non-zero counts,
?non_empty=1, the 422 envelope and an unauthenticated 401 — candidates for the next PHP recording session; Go tests cover them until then. - Moving active-collection resolution into the org-context pipeline stage through a plugin-supplied resolver hook — reconsider in Phase 6 or Phase 12 when many handlers share it.
- Guard registry with the three auth groups, named rate-limit buckets, full CORS and locale negotiation — Phase 6.
- Register, login, refresh and real token issuing in the user plugin — Phase 7; removes the seed hook's JWT minting.
- The separate pgx pool for River's LISTEN/NOTIFY (second half of DATA-01) — Phase 11.
- Production data import from the PHP database — Phase 15 cutover.
Phase: 03-first-vertical-slice-genres-end-to-end Context gathered: 2026-09-17