Files
summercms/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md

146 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3: First vertical slice — genres end to end - Context
**Gathered:** 2026-09-17
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 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.
</domain>
<decisions>
## Implementation Decisions
### 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(*)`, select `id`, `name`, `slug`, `COALESCE(album_count, 0)`, order by name with the Polish ordering, serialize as `{"data":[...]}`. The fixture shows `album_count: 0` everywhere, 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_genres` in full, and minimal `users`, 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.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 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. **Superseded 2026-10-01** by .planning/notes/lagoon-per-query-collation.md: the Polish order is now a per-query COLLATE "pl-x-icu" via lagoon.Collate, and lagoon no longer checks the database locale.
### 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.
- **D-08:** A stub `golem15.user` plugin exists in `fonoteka.go` from this phase, mirroring PHP ownership: it owns the minimal `User` model and its migration and registers the named middleware `jwt.auth`. `golem15.fonoteka` declares `Requires()` on it and references `jwt.auth` by name. Phase 7 fills this plugin in rather than relocating code.
- **D-09:** Framework `bouncer` ships 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 small `UserProvider`-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.php` produces. 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.user` namespace key, overridable by a `SUMMER_…` 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-Language` into 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.fonoteka` registers `inv.must-change-password` (port of `RequirePasswordChange.php`) and attaches it by name after `jwt.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.php` is 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 plain `http.HandlerFunc`/`http.Handler` so 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 by `surf`'s own tests and a route in `examples/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_genres` is created directly with no `item_categories` detour. 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.fonoteka` set, 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.Migration` set through a capability interface; sets run in plugin dependency order (`golem15.user` before `golem15.fonoteka`) with per-plugin version tracking, so rolling back one plugin never touches another's history (ARCHITECTURE.md Anti-Pattern 4). `AutoMigrate` is not used anywhere, tests included. Up and down are both exercised in tests for each set.
- **D-20:** The temporary `genres` seed hook (Phase 2 D-10) in `fonoteka.go/parity` inserts alice and her collection through GORM, mints her token with the test secret into `jwt: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 and `tide`'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 from `pending` to `ported`. `newTarget` returns the real app handler instead of the synthetic one.
### Claude's Discretion
- Package internals and file layout of `surf`, `lagoon` and `bouncer`; exact capability interface names in `pact` (follow ARCHITECTURE.md Pattern 1 and the Phase 1 naming).
- DB config section shape (`database.*` keys, pool sizes) and how the shared `*sql.DB` is published on the `backpack` container. 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 `serve` and `migrate:*` command set on the `bonfire` kernel: at minimum `serve`, `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 `[]` not `null` for an empty list (Pitfall 4).
- How `fonoteka.go` is laid out as a workspace (`plugins/golem15/user`, `plugins/golem15/fonoteka`, generated `main.go` and `plugins.gen.go` via `summer build`, `summer.yaml` manifest order).
- Plan count and split, subject to the repo's plan-count checkpoint and "unit tests are the last plan" rules.
</decisions>
<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`) and `context()`. The handler being ported; `store()` is out of scope.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` lines 61–80 and 271 — the two JWT groups (`jwt.auth`, `bindings`; and `jwt.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.php` and the `Album::accessibleBy` scope in `models/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` — `serializeGenreAggregate` field list and types.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.php` and `/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.php` and `Plugin.php` around 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.yaml` around line 1513 — the route entry to flip from `pending` to `ported` and 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` — how `jwt:alice` is normally captured, which the hook substitutes for.
- `.planning/phases/02-api-parity-harness-bootstrap/02-CONTEXT.md` D-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.md` DATA-01, DATA-02, HTTP-01, HTTP-02, QA-04; `.planning/ROADMAP.md` Phase 3 section.
- `.planning/phases/01-framework-kernel-foundation/01-CONTEXT.md` D-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 that `HasRoutes`/`HasModels`/`HasMigrations` are 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 vet` and `go test ./...` green at every commit.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `summercms.go` kernel 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` (currently `HasCommands`, `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.go` with testcontainers Postgres already starting in `TestMain`, and `scripts/check-phase2.sh` in 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.Context` with 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.go` plugins.
### Integration Points
- `pact` gains the route, middleware, migration and model capability interfaces; `party` boot wires them: migrations collected in dependency order, middleware names registered, route groups mounted on one ServeMux behind the pipeline.
- `bonfire` kernel gains `serve` and `migrate:*`, so both `summer` and the generated `fonoteka` binary expose them.
- `fonoteka.go/parity/parity_test.go` `newTarget` switches from the synthetic handler to the booted app handler; the `genres` seed hook is registered in `seedHooks`.
- `fonoteka.go` has no `go.work` or plugins yet: this phase creates the workspace, the two plugins and the generated app entry point.
</code_context>
<specifics>
## 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.fonoteka` requires `golem15.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.
</specifics>
<deferred>
## Deferred Ideas
- 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.
</deferred>
---
*Phase: 03-first-vertical-slice-genres-end-to-end*
*Context gathered: 2026-09-17*