--- phase: 03-first-vertical-slice-genres-end-to-end plan: 03 subsystem: api tags: [servemux, typed-params, gormigrate, rollback, parity, jwt, seed-hook] requires: - phase: 03-first-vertical-slice-genres-end-to-end provides: Shared pgx-stdlib pool, JWT genre route, tenant-scoped album counts - phase: 02-api-parity-harness-bootstrap provides: App-owned newTarget/seedHooks seam and recorded GET genres fixture provides: - Typed integer path params plus registration-time regex and enum constraints - Per-plugin migrate:status IDs and migrate:rollback --plugin isolation - Temporary trusted genres seed hook and real-app fixture replay - Honest corpus counts: 154 recorded, 1 ported/passing, 153 pending affects: [03-04, 05, 06, 07, 12] tech-stack: added: [] patterns: - surf.Where/WhereIn compile constraints at registration; request text never builds regex or SQL - Ported routes with seed_hook skip global PHP bootstrap replay - Corpus passing increments only after the ported subtest succeeds key-files: created: - surf/params.go - ../fonoteka.go/parity/genres_seed_test.go modified: - surf/router.go - pact/capabilities.go - lagoon/migrations.go - lagoon/commands.go - examples/hello/plugins/greeter/plugin.go - ../fonoteka.go/parity/parity_test.go - ../fonoteka.go/parity/manifest.yaml - ../fonoteka.go/README.md key-decisions: - "Route constraints compile regex and enum allow-lists at registration; request path text is only matched" - "A ported route with a trusted seed_hook skips the global PHP bootstrap replay" - "Corpus passing increments only after the ported subtest succeeds; pending never counts as passing" - "Colliding fixture IDs are derived from CanonicalGenres seed order (rock=1, electronic=2, jazz=4)" patterns-established: - "Pattern: pact.Router.Where/WhereIn plus surf.IntParam; malformed and unknown IDs both 404" - "Pattern: lagoon.ErrUnknownPlugin named error; rollback of golem15.fonoteka seed leaves user history intact" - "Pattern: seedHooks[\"genres\"] mints a test-only JWT into an in-memory tide.Store" requirements-completed: [DATA-02, HTTP-01, HTTP-02, QA-04] duration: 8min completed: 2026-09-17 --- # Phase 03 Plan 03: Typed params, isolated rollback, first real parity pass Summary **Typed ServeMux params, per-plugin `migrate:rollback`, and one honest Go pass of the recorded PHP genres fixture (154 recorded, 1 passing, 153 pending)** ## Performance - **Duration:** 8 min - **Started:** 2026-09-17T18:14:59Z - **Completed:** 2026-09-17T18:23:20Z - **Tasks:** 2 - **Files modified:** 17 ## Accomplishments - `surf.IntParam`, `Where`, and `WhereIn` compile regex/enum constraints at registration; `examples/hello` serves `/items/1`, 404s malformed and unknown IDs, and rejects enum values outside the allow-list. - `migrate:status` lists each plugin's applied IDs; `migrate:rollback --plugin=golem15.fonoteka` rolls back only the genre seed and returns `lagoon.ErrUnknownPlugin` for a missing plugin. - `newTarget` boots the real app; the temporary `genres` hook seeds Alice, an owned collection, a test-only JWT, and colliding IDs from CanonicalGenres order. The unmodified `get_genres_jwt.yaml` passes. Corpus: 154 recorded, 1 ported/passing, 153 pending. ## Task Commits Each task was committed atomically (framework `summercms.go` then app `fonoteka.go` when both change): 1. **Task 1: Expose reversible plugin migrations and typed route parameters** - `8e3bf266d860c7416e8988845b70b190535c4b55` (feat, summercms.go) - `91e3df41e33c85818f4cc3470668853f04b67315` (feat, fonoteka.go) 2. **Task 2: Turn the recorded genres route into one honest Go parity pass** - `0cc1a4da84ced8ed2451f2ea1c9a045f1c2ea682` (feat, fonoteka.go) **Plan metadata:** (this commit) ## Files Created/Modified - `surf/params.go` — `IntParam`, `Regex`, `Enum`; constraints compiled at registration - `pact/capabilities.go` — `Router.Where` / `WhereIn` - `lagoon/migrations.go` — `ErrUnknownPlugin`, `ErrNoMigrations`, isolated `RollbackLast` - `lagoon/commands.go` — `migrate:status` prints every applied ID - `examples/hello/plugins/greeter/plugin.go` — `{id}` regex route and enum `/kinds/{kind}` - `../fonoteka.go/parity/genres_seed_test.go` — trusted GORM Alice/collection hook and test-only JWT - `../fonoteka.go/parity/parity_test.go` — real `app.Handler`, skip global bootstrap when `seed_hook` is set, passing count after successful replay, mutated-response check - `../fonoteka.go/parity/manifest.yaml` — `GET /_fonoteka/api/v1/genres jwt` is `ported` with `seed_hook: genres` - `../fonoteka.go/README.md` — ICU `pl-PL` create, `migrate`, `migrate:status`, `migrate:rollback --plugin=golem15.fonoteka`, `serve` ## Decisions Made - Constraints are compiled when the route is registered; enum values are an allow-list, not a regex built from request text. - A ported route with `seed_hook` does not replay unported global bootstrap endpoints. - Passing is incremented only after the ported subtest succeeds. - Fixture collisions `id:token=1`, `id:wishlist-album=2`, `id:genre=4` come from CanonicalGenres insertion order. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Synthetic proof tests kept `newSyntheticHandler` after `newTarget` became the real app** - **Found during:** Task 2 (real handler swap) - **Issue:** `TestParitySynthetic` and the contract `synthetic-postgres` subtest posted `/synthetic/items` through `newTarget`. The real app does not mount those routes. - **Fix:** Call `newSyntheticHandler` for the Phase 2 synthetic proof; `newTarget` boots `app.Handler`. - **Files modified:** `../fonoteka.go/parity/synthetic_test.go`, `../fonoteka.go/parity/parity_contract_test.go`, `../fonoteka.go/parity/parity_test.go` - **Verification:** `go test ./parity` including `TestParitySynthetic` and `TestParityCorpus` - **Committed in:** `0cc1a4d` (Task 2) **2. [Rule 3 - Blocking] Rollback isolation uses a dedicated `rollback_iso` database** - **Found during:** Task 1 (rollback test) - **Issue:** Shared TestMain Postgres would lose the 15 genre rows (and shift serial IDs) if seed rollback ran in-place. - **Fix:** Create ICU `pl-PL` database `rollback_iso`, migrate/rollback there, leave the parity pool untouched. - **Files modified:** `../fonoteka.go/parity/migrate_test.go` - **Verification:** `TestRollbackLastIsolatesFonoteka` plus later genre/parity tests still see ids 1–15 - **Committed in:** `91e3df4` (Task 1) **3. [Discretion] `pact.Router` gained `Where`/`WhereIn`** - **Found during:** Task 1 (typed params) - **Issue:** Plan listed `surf/params.go` but plugins receive `pact.Router`; PHP `->where()` needs a method on that interface. - **Fix:** Add `Where`/`WhereIn` to `pact.Router` and implement them on `*surf.Router`/`*surf.Group`. - **Files modified:** `pact/capabilities.go`, `surf/router.go` - **Verification:** hello typed-route tests and `go vet ./...` - **Committed in:** `8e3bf26` (Task 1) --- **Total deviations:** 3 (2 blocking test-isolation, 1 interface completeness) **Impact on plan:** No scope creep. QA-04 is green on one route; 153 remain pending. ## Issues Encountered None that blocked the slice. `go.work.sum` and `.planning/config.json` `_auto_chain_active` were left uncommitted. ## User Setup Required None - no external service configuration required beyond the existing Postgres ICU `pl-PL` database. ## Next Phase Readiness Ready for `03-04-PLAN.md` (unit, integration, and security verification). The genres corpus route is `ported`. Do not treat pending routes as passing. ## Self-Check: PASSED - Key files exist on disk (`surf/params.go`, `lagoon/commands.go`, `../fonoteka.go/parity/genres_seed_test.go`, `../fonoteka.go/parity/parity_test.go`) - `git log --grep=03-03` returns Task 1 and Task 2 commits in both repos - Acceptance: per-plugin rollback isolation, hello typed/enum 404s, unmodified `get_genres_jwt.yaml` 200 against the real handler, corpus 154/1/153, mutated response fails, no tide or fixture edits, `go vet`/`go test ./...` green in both repos --- *Phase: 03-first-vertical-slice-genres-end-to-end* *Completed: 2026-09-17*