diff --git a/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md new file mode 100644 index 0000000..c374b23 --- /dev/null +++ b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md @@ -0,0 +1,145 @@ +# Phase 3: First vertical slice — genres end to end - Context + +**Gathered:** 2026-09-17 +**Status:** Ready for planning + + +## 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. + + + + +## 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 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. + +### 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. + + + + +## 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. + + + + +## 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. + + + + +## 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. + + + + +## 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. + + + +--- + +*Phase: 03-first-vertical-slice-genres-end-to-end* +*Context gathered: 2026-09-17* diff --git a/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-DISCUSSION-LOG.md b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-DISCUSSION-LOG.md new file mode 100644 index 0000000..d04652d --- /dev/null +++ b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-DISCUSSION-LOG.md @@ -0,0 +1,219 @@ +# Phase 3: First vertical slice — genres end to end - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-09-17 +**Phase:** 3-first-vertical-slice-genres-end-to-end +**Areas discussed:** Handler fidelity, Throwaway JWT guard, Pipeline stage depth, Migrations and seed data + +--- + +## Handler fidelity + +### How faithful should the Go genres handler be to the PHP query? + +| Option | Description | Selected | +|--------|-------------|----------| +| Faithful query, minimal tables | Real user → active collection → accessible-album count join; only the columns the query touches; Phase 5 widens | ✓ | +| Genres-only, count hardcoded 0 | Fastest green diff; handler rewritten in Phase 12; proves nothing about joins | | +| Genres + albums count, no access scoping | Real COUNT join, skips user/active-collection resolution | | + +**User's choice:** Faithful query, minimal tables +**Notes:** Scout found the fixture shows `album_count: 0` for every genre, so the diff cannot force this choice. + +### How do we prove the non-zero count path? + +| Option | Description | Selected | +|--------|-------------|----------| +| Go integration test alongside parity | testcontainers test inserts albums for alice and a foreign collection; fixture untouched | ✓ | +| Re-record a richer fixture from PHP | New manifest cases recorded after genre-tagged albums exist; reopens Phase 2 corpus | | +| Both | Integration test plus a second recorded case | | + +**User's choice:** Go integration test alongside parity + +### Where does active collection resolution live? + +| Option | Description | Selected | +|--------|-------------|----------| +| Minimal resolver in the fonoteka plugin | Small service porting the default path of ActiveCollectionResolver; Phase 12 extends | ✓ | +| Org-context middleware sets it | Pipeline stage resolves via a plugin-supplied hook | | +| You decide | Research compares against other controllers' context() use | | + +**User's choice:** Minimal resolver in the fonoteka plugin + +### Is ?non_empty validation (422) and filtering part of this slice? + +| Option | Description | Selected | +|--------|-------------|----------| +| Port it, test it in Go only | Whole handler ported incl. 422 envelope and filter; no new fixtures | ✓ | +| Skip until Phase 12 | Only the recorded 200 path, with a TODO | | +| Port it and record fixtures | Add non_empty=1 and 422 cases to the manifest, re-record | | + +**User's choice:** Port it, test it in Go only + +### PolishOrder collation on Postgres + +| Option | Description | Selected | +|--------|-------------|----------| +| Research picks, I confirm at plan review | Researcher compares query COLLATE, column collation, DB locale; lands as a lagoon helper | ✓ | +| ICU collation in the query | Lock `COLLATE "pl-x-icu"` via helper now | | +| Database-level Polish locale | Plain ORDER BY; deployment and testcontainers constraint | | + +**User's choice:** Research picks, I confirm at plan review + +--- + +## Throwaway JWT guard + +### What does the guard verify? + +| Option | Description | Selected | +|--------|-------------|----------| +| Real HS256 verify + user row lookup | Signature, pinned alg, exp, sub, user row into context; only issuing is throwaway | ✓ | +| Static seeded-token table | Bearer string compared to a token list; no crypto | | +| Real verify, no DB lookup | Identity from sub only | | + +**User's choice:** Real HS256 verify + user row lookup +**Notes:** Scout: PHP `jwt.auth` is registered by the user plugin; HS256, `JWT_SECRET` from env. + +### Which module owns the guard and minimal users table until Phase 7? + +| Option | Description | Selected | +|--------|-------------|----------| +| Stub user plugin in fonoteka.go | Real plugin with minimal User, migration, registers `jwt.auth`; fonoteka Requires() it | ✓ | +| Framework bouncer owns verify, fonoteka plugin owns users | Less scaffolding now, a move later | | +| Everything temporary inside the fonoteka plugin | One plugin, throwaway auth file | | + +**User's choice:** Stub user plugin in fonoteka.go + +### How far does bouncer go? + +| Option | Description | Selected | +|--------|-------------|----------| +| Verifier + context accessors only | HS256 helper, user context key and accessors, UserProvider interface | ✓ | +| Guard registry skeleton now | Three auth groups declared, jwt implemented | | +| No bouncer yet | All auth code in the user plugin until Phase 6 | | + +**User's choice:** Verifier + context accessors only + +### Which failure responses does the guard port now? + +| Option | Description | Selected | +|--------|-------------|----------| +| Port PHP's 401 bodies, Go-tested | Same status and JSON as JwtAuthenticate.php; no new fixtures | ✓ | +| Add a recorded 401 genres case | Extend manifest, record from PHP | | +| Plain 401, shape later | Exact bodies left to Phase 6/7 | | + +**User's choice:** Port PHP's 401 bodies, Go-tested + +--- + +## Pipeline stage depth + +### How real are the stages genres doesn't exercise? + +| Option | Description | Selected | +|--------|-------------|----------| +| Real slot, minimal behavior each | Named middleware in fixed order; CORS preflight from config, locale into context, org context pass-through, no-op limiter behind interface | ✓ | +| Recover + auth real, the rest pass-through | Five named no-ops | | +| All real now | Pulls most of Phase 6 forward | | + +**User's choice:** Real slot, minimal behavior each + +### must-change-password (423) handling + +| Option | Description | Selected | +|--------|-------------|----------| +| Real check, owned by the plugin that owns it in PHP | fonoteka plugin registers `inv.must-change-password`, attached by name after `jwt.auth`; real 423 body | ✓ | +| Named pass-through | Slot exists, always passes | | + +**User's choice:** Real check, owned by the plugin that owns it in PHP + +### Route declaration API in surf + +| Option | Description | Selected | +|--------|-------------|----------| +| Laravel-like group builder over ServeMux | `r.Group(prefix, surf.Use(names...), func(g){ g.Get(...) })`; plain handlers | ✓ | +| Bare ServeMux patterns + explicit wrap | Zero abstraction, repeated prefixes | | +| Declarative route table | `[]surf.Route` structs, introspectable | | + +**User's choice:** Laravel-like group builder over ServeMux + +### Typed params now? + +| Option | Description | Selected | +|--------|-------------|----------| +| Ship the helper, exercise it on a test route | Integer accessor with 404-on-malformed, tested in surf and examples/hello | ✓ | +| Defer to the first route that needs it | Strictly interleaved; criterion 2 partly unmet | | + +**User's choice:** Ship the helper, exercise it on a test route + +--- + +## Migrations and seed data + +### Relation to PHP migration history + +| Option | Description | Selected | +|--------|-------------|----------| +| Squashed final-state migrations per table | One migration per table in final shape, comment naming folded PHP files | ✓ | +| Mirror PHP one to one | One Go migration per PHP file incl. rename detour | | +| Squash now, decide again in Phase 5 | Policy revisited later | | + +**User's choice:** Squashed final-state migrations per table + +### Partial tables vs Phase 5 + +| Option | Description | Selected | +|--------|-------------|----------| +| Minimal now, Phase 5 adds ALTER migrations | Phase 3 migrations permanent, never edited | ✓ | +| Rewrite in Phase 5 | Provisional migrations replaced pre-release | | +| Full-shape tables now | Complete column set without Phase 5's security pass | | + +**User's choice:** Minimal now, Phase 5 adds ALTER migrations + +### Source of the 15 seeded genres + +| Option | Description | Selected | +|--------|-------------|----------| +| A data migration, as in PHP | Idempotent by slug, PHP order, Rollback removes them | ✓ | +| The parity seed hook inserts them | Test-only data | | +| A db:seed style command | New framework concept | | + +**User's choice:** A data migration, as in PHP + +### Temporary genres seed hook + +| Option | Description | Selected | +|--------|-------------|----------| +| Insert alice + collection, mint her JWT, set the colliding id vars | Fixture untouched; hook declared temporary in manifest | ✓ | +| Same, but fix the scrubber collisions first | Tighten tide substitution, re-scrub 154 fixtures | | +| You decide | Planner checks collision frequency | | + +**User's choice:** Insert alice + collection, mint her JWT, set the colliding id vars +**Notes:** Scrubber fix recorded as a deferred idea. + +--- + +## Claude's Discretion + +- Package internals of `surf`, `lagoon`, `bouncer`; capability interface names in `pact` +- DB config keys and how the shared `*sql.DB` is published; River listener pool seam left for Phase 11 +- Per-plugin gormigrate version-table naming +- `serve` and `migrate:*` command set and graceful shutdown +- Fail-boot behavior for unknown middleware names +- Genre aggregate DTO; `[]` for empty lists +- `fonoteka.go` workspace layout +- JWT secret config key and fail-boot on empty secret (D-11, not asked; recorded as a security default) +- Postgres Polish ordering: research proposes, user confirms at plan review + +## Deferred Ideas + +- Tighten `tide` scrubber id-placeholder collisions and re-scrub the corpus +- Recorded fixtures for non-zero counts, `non_empty=1`, 422 and 401 on genres +- Active-collection resolution via the org-context stage (Phase 6/12) +- Guard registry, rate-limit buckets, full CORS/locale (Phase 6) +- Real token issuing in the user plugin (Phase 7) +- River LISTEN/NOTIFY pgx pool (Phase 11) +- Production data import (Phase 15)