Files
summercms/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
2026-09-17 18:40:13 +02:00

20 KiB
Raw Blame History

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 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.

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_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>

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