Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-01-PLAN.md
2026-09-18 17:31:53 +02:00

324 lines
31 KiB
Markdown

---
phase: 05-data-layer-full-fidelity
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- .planning/notes/plugin-layout-winter-directories.md
- .planning/todos/pending/verify-models-leaf-rule.md
- .planning/todos/done/verify-models-leaf-rule.md
- .planning/ROADMAP.md
- .planning/REQUIREMENTS.md
- fonoteka.go/plugins/golem15/fonoteka/models/genre.go
- fonoteka.go/plugins/golem15/fonoteka/models/album.go
- fonoteka.go/plugins/golem15/fonoteka/models/collection.go
- fonoteka.go/plugins/golem15/fonoteka/models/collection_editor.go
- fonoteka.go/plugins/golem15/fonoteka/models/user_collection_context.go
- fonoteka.go/plugins/golem15/fonoteka/models/registry.go
- fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
- fonoteka.go/plugins/golem15/fonoteka/classes/registry.go
- fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
- fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go
- fonoteka.go/plugins/golem15/fonoteka/updates/00_base.go
- fonoteka.go/plugins/golem15/fonoteka/updates/registry.go
- fonoteka.go/plugins/golem15/fonoteka/plugin.go
- fonoteka.go/plugins/golem15/fonoteka/routes.go
- fonoteka.go/plugins/golem15/user/models/user.go
- fonoteka.go/plugins/golem15/user/models/registry.go
- fonoteka.go/plugins/golem15/user/classes/user_lookup.go
- fonoteka.go/plugins/golem15/user/updates/00_base.go
- fonoteka.go/plugins/golem15/user/updates/registry.go
- fonoteka.go/plugins/golem15/user/plugin.go
- fonoteka.go/app/app.go
- fonoteka.go/parity/migrate_test.go
- fonoteka.go/parity/parity_test.go
- fonoteka.go/parity/genre_smoke_test.go
- fonoteka.go/parity/genre_integration_test.go
- fonoteka.go/parity/genre_security_test.go
- fonoteka.go/parity/genres_seed_test.go
- summercms.go/lagoon/fill.go
- summercms.go/lagoon/fill_test.go
- summercms.go/lagoon/lifecycle.go
- summercms.go/lagoon/lifecycle_test.go
- summercms.go/lagoon/paginate.go
- summercms.go/lagoon/paginate_test.go
- summercms.go/lagoon/relations.go
- summercms.go/lagoon/relations_test.go
autonomous: true
requirements: [DATA-03, DATA-06, DATA-10]
must_haves:
truths:
- "The models-leaf rule (models/ never imports classes/controllers/console/jobs/middleware/updates within the same plugin module) holds against three more real plugins (keios.eu, jz, pxpx) before the 25-model port begins, or the layout note is amended (folded todo)"
- "golem15.fonoteka and golem15.user are Winter-directory-shaped Go plugins with collision-free registries so later waves add files, not edit shared ones, and the genres route still passes its recorded parity fixture unchanged"
- "A model can declare Winter-named lifecycle hooks and a child row can be soft-deleted inside the same transaction as its parent's delete (D-03 discretion, DATA-03 foundation) — the concrete Collection→Album cascade using this primitive is Plan 05-02's job once Collection is widened"
- "lagoon.Fill copies onto a model only keys that are both requested and fillable, and silently drops the rest with a once-per-call-site non-production log line (D-05, D-06)"
- "lagoon.Paginate emits {data, meta{current_page,last_page,per_page,total}} with no links key (DATA-10)"
- "ROADMAP.md and REQUIREMENTS.md no longer present '27 migrations' as an acceptance target, and criterion 3's HTTP-endpoint-fuzz clause is documented as moved to Phase 12 (D-01, D-07)"
artifacts:
- path: summercms.go/lagoon/fill.go
provides: "Fill(model, allowed, requested, production) + HasFillable/HasHidden interfaces"
- path: summercms.go/lagoon/lifecycle.go
provides: "GORM-native hook interfaces + WithSoftDeleteCascade"
- path: summercms.go/lagoon/paginate.go
provides: "Page[T]/PageMeta + Paginate[T]"
- path: summercms.go/lagoon/relations.go
provides: "RegisterJoinTable wrapper + pivot-write contract doc"
- path: fonoteka.go/plugins/golem15/fonoteka/models/registry.go
provides: "Register/All so later plans add model files without editing plugin.go"
- path: fonoteka.go/plugins/golem15/fonoteka/updates/registry.go
provides: "Register/All so later plans add migration files without editing plugin.go"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/plugin.go
to: fonoteka.go/plugins/golem15/fonoteka/models/registry.go
via: "Models() returns models.All()"
pattern: "func \\(p \\*Plugin\\) Models\\(\\) \\[\\]any \\{\\s*return models\\.All\\(\\)"
- from: fonoteka.go/plugins/golem15/fonoteka/plugin.go
to: fonoteka.go/plugins/golem15/fonoteka/updates/registry.go
via: "Migrations() returns updates.All()"
pattern: "func \\(p \\*Plugin\\) Migrations\\(\\).*\\{\\s*return updates\\.All\\(\\)"
---
<objective>
Lay the collision-free foundation the remaining five plans build on: confirm the models-leaf layout rule against three more real plugins (the folded todo) and gate the restructure on that result, restructure both Phase-3 `fonoteka.go` plugins into the Winter directory layout with self-registering `models`/`updates`/`classes` registries so wave-2 plans (05-02, 05-03) can each add new files without editing a shared line, and ship the `lagoon` write-path/read-path primitives (lifecycle hooks, transactional soft-delete cascade, `Fill`/`Fillable`/`Hidden`, ordered-pivot-read convention, pagination envelope) every later plan's models depend on.
Purpose: every later plan in this phase only ever adds new files under `models/`, `updates/`, `classes/` — this plan is what makes that true, and it ships the framework primitives (D-05, D-06, D-08, DATA-03, DATA-10) with no real Płytarium model yet depending on them, so they get their own focused tests before 25 models start using them. The models-leaf probe is gated as its own task (Task 1) before the restructure (Task 2) actually moves code, so an "other"-classified edge stops the restructure rather than being discovered mid-move.
Output: 3 new dated evidence sections plus a closed folded todo; restructured `fonoteka.go` plugins with working registries and a still-green genres parity fixture; `lagoon.Fill`, `lagoon`'s five lifecycle hook interfaces, `lagoon.WithSoftDeleteCascade`, `lagoon.Paginate`, `lagoon.RegisterJoinTable`; corrected ROADMAP.md/REQUIREMENTS.md wording.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/notes/plugin-layout-winter-directories.md
@.planning/todos/pending/verify-models-leaf-rule.md
</context>
<interfaces>
<!-- Existing summercms.go primitives this plan's code must match in shape -->
From lagoon/order.go (allow-list-then-apply shape to reuse for Fill and RegisterJoinTable):
```go
func orderClause(column, dir string, allowed []string) (string, error) {
if !allowListed(column, allowed) {
return "", fmt.Errorf("lagoon: order column %q is not allow-listed", column)
}
...
}
func allowListed(column string, allowed []string) bool { ... }
```
From lagoon/migrations.go (per-plugin history table + migrator() helper, unexported, same package — the framework-owned `attach` migration set Plan 04 adds later will reuse `migrator()` with a synthetic plugin id; do not change this file's public signatures):
```go
func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) (*gormigrate.Gormigrate, error)
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error
```
From phrasebook/translator.go (once-per-key non-production log dedup to copy for Fill's dropped-key logging):
```go
func (t *Translator) logMissing(key string) {
if t.production { return }
if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded { return }
slog.Warn("phrasebook: missing translation key", "key", key)
}
```
From pact/capabilities.go (HasModels/HasMigrations — plugin.go's Models()/Migrations() must keep satisfying these):
```go
type HasModels interface { Models() []any }
type HasMigrations interface { Migrations() []*gormigrate.Migration }
```
From internal/build/leaf.go (the enforcement this plan's restructure must pass):
```go
var modelsSiblingLeaves = []string{"classes", "controllers", "console", "jobs", "middleware", "updates"}
// inspectModelsImports fails if any file under <plugin>/models imports <module>/<sibling>[/...]
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1 (.planning): Folded-todo probe — verify the models-leaf rule against three more real plugins; gate the restructure on the result</name>
<files>
.planning/notes/plugin-layout-winter-directories.md,
.planning/todos/pending/verify-models-leaf-rule.md (removed) / .planning/todos/done/verify-models-leaf-rule.md (added)
</files>
<read_first>
.planning/todos/pending/verify-models-leaf-rule.md
.planning/notes/plugin-layout-winter-directories.md
/media/nvme/dev/golem15/keios.eu/plugins/golem15/user (models/, classes/)
/media/nvme/dev/jz/wavepath.org/plugins/golem15/chat (models/, classes/)
/media/nvme/dev/pxpx/PXSTARTER/plugins/pixelpixel/checkout (models/, classes/)
</read_first>
<action>
Repeat the Fonoteka evidence-gathering method from `.planning/notes/plugin-layout-winter-directories.md`'s "Evidence" section on three real plugins: `/media/nvme/dev/golem15/keios.eu/plugins/golem15/user` (keios.eu), `/media/nvme/dev/jz/wavepath.org/plugins/golem15/chat` (jz), `/media/nvme/dev/pxpx/PXSTARTER/plugins/pixelpixel/checkout` (pxpx). For each, run the todo's grep pattern shape (`grep -rn "<Vendor>\<Plugin>\Classes" <plugin>/models` and the reverse `classes/ -> models/` direction) and classify every `models/ -> classes/` (or `models/ -> any sibling`) edge as cast, service-calling hook, or other. Append one dated "## Evidence (...)" block per plugin under the existing Fonoteka evidence in `.planning/notes/plugin-layout-winter-directories.md`, each with the same edge-count table shape.
Gate: if every edge across the three plugins is cast-or-hook, move `.planning/todos/pending/verify-models-leaf-rule.md` to `.planning/todos/done/verify-models-leaf-rule.md` (create `done/` if it does not exist) with a one-line resolution note referencing the new evidence, and Task 2 (the restructure) proceeds. If any edge is "other", stop here: amend the rule text in the same note describing the exception, leave the todo in `pending/`, and do not execute Task 2 in this run — the restructure must not proceed under an unconfirmed rule (flag this to the orchestrator as a blocker requiring a plan revision before Task 2 runs).
</action>
<verify>
<automated>test -f .planning/todos/done/verify-models-leaf-rule.md && test ! -e .planning/todos/pending/verify-models-leaf-rule.md && [ "$(grep -c '^## Evidence' .planning/notes/plugin-layout-winter-directories.md)" -ge 4 ]</automated>
</verify>
<acceptance_criteria>
- `.planning/notes/plugin-layout-winter-directories.md` contains 3 new dated "## Evidence (...)" sections (keios.eu, jz, pxpx) in addition to the existing Fonoteka one, or the rule text is visibly amended if an "other" edge was found.
- `.planning/todos/pending/verify-models-leaf-rule.md` no longer exists; `.planning/todos/done/verify-models-leaf-rule.md` exists — only if the gate passed. If the gate did not pass, the todo stays in `pending/` and Task 2 does not run this session.
</acceptance_criteria>
<done>The models-leaf rule is confirmed (or amended) against 3 more real plugins with the todo closed, or the restructure is explicitly blocked pending a rule amendment.</done>
</task>
<task type="auto">
<name>Task 2 (fonoteka.go): Restructure both Phase-3 plugins into the Winter layout with collision-free registries</name>
<files>
fonoteka.go/plugins/golem15/fonoteka/{models,classes,controllers,middleware,updates}/*.go,
fonoteka.go/plugins/golem15/fonoteka/plugin.go, fonoteka.go/plugins/golem15/fonoteka/routes.go,
fonoteka.go/plugins/golem15/user/{models,classes,updates}/*.go, fonoteka.go/plugins/golem15/user/plugin.go,
fonoteka.go/parity/*_test.go
</files>
<read_first>
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/genre.go
fonoteka.go/plugins/golem15/fonoteka/active_collection.go
fonoteka.go/plugins/golem15/fonoteka/genre_handler.go
fonoteka.go/plugins/golem15/fonoteka/migrations.go
fonoteka.go/plugins/golem15/fonoteka/password.go
fonoteka.go/plugins/golem15/user/plugin.go
fonoteka.go/plugins/golem15/user/user.go
fonoteka.go/plugins/golem15/user/migrations.go
fonoteka.go/app/app.go
fonoteka.go/parity/migrate_test.go
fonoteka.go/parity/parity_test.go
summercms.go/internal/build/leaf.go
summercms.go/pact/capabilities.go
</read_first>
<action>
Precondition: only run this task if Task 1's gate passed (the todo moved to `done/`). If it did not, stop and surface the blocker instead of restructuring under an unconfirmed rule.
Under each of `fonoteka.go/plugins/golem15/fonoteka` and `fonoteka.go/plugins/golem15/user`, create `models/`, `classes/`, `updates/` subpackages (plus `controllers/` and `middleware/` for fonoteka only, since user has no HTTP handler yet). Move struct-only files verbatim into `models/` (package name `models`): `genre.go`'s `Genre`/`Album` structs -> `models/genre.go` + `models/album.go`; `active_collection.go`'s `Collection`/`CollectionEditor`/`UserCollectionContext` structs -> `models/collection.go`, `models/collection_editor.go`, `models/user_collection_context.go`; `user.go`'s `User` struct -> `models/user.go`. Move service-calling / DB-lookup functions into `classes/` (package name `classes`): `active_collection.go`'s `ResolveActiveCollection`, `AccessibleByMembership`, `findAccessibleCollection`, `firstAccessibleRealCollection`, `persistContext`, `kindRealCollection` -> `classes/active_collection.go`; `user.go`'s `gormUsers`, `jwtSecret` -> `classes/user_lookup.go`. Move `genre_handler.go` into `controllers/genre_controller.go` (package `controllers`). Move `password.go`'s `mustChangePassword` into `middleware/must_change_password.go` (package `middleware`). Move `migrations.go`'s `var migrations` slice into `updates/00_base.go` (package `updates`), unchanged byte-for-byte (P3 D-17: shipped migrations are never edited, only moved).
Add a self-registering collision-free registry to each plugin so future plans (05-02..05-05) only ever add new files: in `models/registry.go` (package `models`), declare `var all []any` and `func Register(models ...any) { all = append(all, models...) }` / `func All() []any { return all }`; each moved model file's own `init()` calls `models.Register(Genre{})` etc. (one `Register` call per file, at the bottom of the file that declares the type). Do the same in `updates/registry.go` (package `updates`): `var all []*gormigrate.Migration`, `Register(ms ...*gormigrate.Migration)`, `All() []*gormigrate.Migration`; `updates/00_base.go`'s `init()` calls `updates.Register(migrations...)` on its own moved slice. Do the same for `classes/registry.go` for future GORM-callback/hook registrants: `var hookRegistrars []func(*gorm.DB) error`, `func RegisterHook(fn func(*gorm.DB) error) { hookRegistrars = append(hookRegistrars, fn) }`, `func RegisterHooks(gdb *gorm.DB) error { for _, fn := range hookRegistrars { if err := fn(gdb); err != nil { return err } }; return nil }` — no registrant exists yet in this plan (Plan 02 adds the first, `ArtistResolver`'s callback), this file just establishes the collector so Plan 02/05 never edit `plugin.go`'s `Boot`.
Update `plugin.go` in both plugins to its final, never-edited-again form: `func (p *Plugin) Models() []any { return models.All() }`, `func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }`, and in `Boot(app *backpack.App) error`, after the existing body, add `gdb, ok := app.Lookup[*gorm.DB](); if ok { if err := classes.RegisterHooks(gdb); err != nil { return err } }` (guarded by `ok` because `Boot` can run before the DB handle is published in some test paths — mirror the existing nil-tolerant style in `active_collection.go`). Move `fonoteka`'s route registration (`Routes(r pact.Router) error`, currently inline in `plugin.go`) into `routes.go` at the plugin root, calling `controllers.ListGenres(p.app)`. Update every import across both plugin modules and in `fonoteka.go/parity/*_test.go` and `fonoteka.go/app/app.go` to the new subpackage import paths (e.g. `git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/models`, `.../controllers`). Do not change any table name, column name, exported HTTP contract, or migration ID — this is a pure package-boundary move.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestParityCorpus</automated>
</verify>
<acceptance_criteria>
- `cd summercms.go && go test ./internal/build/... -run TestCheckModelsLeaf` (or the closest existing leaf-check test) passes against both restructured plugins with zero import violations.
- `grep -rn "^package fonoteka$" fonoteka.go/plugins/golem15/fonoteka/models fonoteka.go/plugins/golem15/fonoteka/classes` returns no matches (subpackages have their own package names).
- `go test ./... -run TestParityCorpus` in `fonoteka.go` reports `cov.Passing == cov.Ported == 1` in the `coverage` subtest (unchanged from the Phase 3/4 baseline) — the genres route still passes byte-for-byte.
- `grep -n "func (p \*Plugin) Models" fonoteka.go/plugins/golem15/fonoteka/plugin.go` shows `return models.All()`; same check for `Migrations` returning `updates.All()`.
</acceptance_criteria>
<done>Both plugins are Winter-directory-shaped with self-registering models/updates/classes registries, and the genres parity fixture is unchanged and green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3 (summercms.go): lagoon write-path primitives — Fill/Fillable/Hidden, lifecycle hooks, transactional soft-delete cascade</name>
<files>summercms.go/lagoon/fill.go, summercms.go/lagoon/fill_test.go, summercms.go/lagoon/lifecycle.go, summercms.go/lagoon/lifecycle_test.go</files>
<read_first>
summercms.go/lagoon/order.go
summercms.go/lagoon/order_test.go
summercms.go/lagoon/connection.go
summercms.go/lagoon/postgres_test.go
summercms.go/phrasebook/translator.go (lines ~179-187, logMissing)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Architecture Patterns write-path diagram)
.planning/research/ARCHITECTURE.md (Pattern 3b)
.planning/research/PITFALLS.md (Pitfall 3)
</read_first>
<behavior>
- `Fill(model, []string{"name"}, map[string]any{"name":"x","collection_id":9}, false)` sets `model.Name = "x"` and leaves any `CollectionID` field untouched; the dropped key is logged once.
- `Fill` called twice in a loop with the same dropped key logs the warning exactly once (dedup by type+key), verified via a captured `slog` handler or exported test hook.
- `Fill(model, allowed, requested, true)` (production=true) never logs, even for a dropped key.
- A fixture model implementing `BeforeCreate(tx *gorm.DB) error` has that hook invoked by a real `tx.Create(&fixture)` against the existing testcontainers Postgres (`lagoon/postgres_test.go`'s `TestMain`).
- `WithSoftDeleteCascade` soft-deletes a fixture child row inside the same transaction as the fixture parent's delete; if the cascade function returns an error, neither parent nor child is deleted (transaction rolls back). This is the primitive Plan 05-02 wires up as `Collection.BeforeDelete` against real Album rows.
</behavior>
<action>
In `lagoon/fill.go`: declare `type HasFillable interface { Fillable() []string }` and `type HasHidden interface { Hidden() []string }` with doc comments naming their PHP `$fillable`/`$hidden` origin (D-05, D-08). Implement `func Fill(model any, allowed []string, requested map[string]any, production bool) error` using `reflect.ValueOf(model).Elem()` field-by-field iteration: match each `requested` key against `allowed` via a linear scan (reuse `order.go`'s `allowListed` shape, exporting a shared helper from `fill.go` if a second copy risks drifting); for an allow-listed+requested key, find the struct field whose `gorm:"column:<key>"` tag matches and `reflect.Value.Set` it (support pointer fields for nullable columns by allocating a new pointer when the incoming value is non-nil, and setting the field to a nil pointer when the incoming value is explicitly `nil` in the map); for a dropped key (requested but not allowed), call `logDroppedKeyOnce(production, reflect.TypeOf(model).Elem().String(), key)` — implement `logDroppedKeyOnce` with a package-level `sync.Map` keyed by `typeName+"."+key`, `slog.Warn` on first sight only, matching `phrasebook.logMissing`'s shape exactly (guard clause `if production { return }` first).
In `lagoon/lifecycle.go`: declare the five hook interfaces using GORM's own native hook signatures (so a model needs no adapter to satisfy both GORM and `lagoon`'s naming): `type HasBeforeValidate interface { BeforeValidate(tx *gorm.DB) error }`, `HasBeforeCreate`, `HasBeforeSave`, `HasBeforeDelete interface { BeforeDelete(tx *gorm.DB) error }`, `HasAfterDelete interface { AfterDelete(tx *gorm.DB) error }` — each named `Has*` for symmetry with `pact`'s `Has*` capability-interface convention, but note in a doc comment that GORM itself dispatches these automatically by method name/signature match (no registration call needed) once a model implements them; the `Has*` names exist purely so other `lagoon`/test code can type-assert "does this model declare hook X" without calling it. Implement `func WithSoftDeleteCascade(tx *gorm.DB, cascade func(tx *gorm.DB) error) error` as a one-line wrapper: `if tx == nil { return fmt.Errorf("lagoon: soft-delete cascade tx is nil") }; return cascade(tx)` — document above it that callers invoke this from their own `BeforeDelete(tx *gorm.DB) error` method (GORM already runs `BeforeDelete` inside the same transaction as the parent `.Delete()` call, so no new transaction is opened here; this function names the pattern and gives it one central place other model files reference, per the "Collection cascades to Album" worked case DATA-03 needs in Plan 02).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run 'TestFill|TestLifecycle|TestWithSoftDeleteCascade'</automated>
</verify>
<acceptance_criteria>
- `lagoon.Fill` table-driven test (no testcontainers, `order_test.go`'s style) proves: allow-listed+requested key is set on the model; dropped key never errors; a second call with the same dropped key does not log twice; `production=true` never logs.
- `lagoon.HasFillable`/`lagoon.HasHidden` are exported interfaces with a doc comment naming `$fillable`/`$hidden`.
- `lifecycle_test.go` proves a fixture model's `BeforeCreate(tx *gorm.DB) error` fires under a real `tx.Create` against the existing testcontainers Postgres in `lagoon/postgres_test.go`.
- `lifecycle_test.go` proves `WithSoftDeleteCascade` soft-deletes a fixture child in the same transaction as the fixture parent's delete, and that a cascade error rolls back both.
- `go vet ./lagoon/...` is clean.
</acceptance_criteria>
<done>`lagoon.Fill`, `lagoon.HasFillable`, `lagoon.HasHidden`, and `lagoon`'s five lifecycle hook interfaces plus `WithSoftDeleteCascade` exist, are unit/integration tested, and `go vet ./...` is clean.</done>
</task>
<task type="auto">
<name>Task 4 (summercms.go, .planning): lagoon read-path primitives — pagination envelope, ordered-pivot-read convention; fix DATA-09/criterion-3 wording</name>
<files>summercms.go/lagoon/paginate.go, summercms.go/lagoon/paginate_test.go, summercms.go/lagoon/relations.go, summercms.go/lagoon/relations_test.go, .planning/ROADMAP.md, .planning/REQUIREMENTS.md</files>
<read_first>
fonoteka.go/plugins/golem15/fonoteka/genre_handler.go (GenreAggregate/GenreList envelope + writeJSON, lines 14-25 and 134-145)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Pattern: GORM many-to-many with pivot business columns", "lagoon/paginate.go (DATA-10)" pattern section, "Squashed Migration List" table)
.planning/ROADMAP.md (Phase 5 section)
.planning/REQUIREMENTS.md (DATA-09 line, Phase 5 criterion 3)
</read_first>
<action>
Create `lagoon/paginate.go` with `type PageMeta struct { CurrentPage int; LastPage int; PerPage int; Total int64 }` (json tags `current_page`, `last_page`, `per_page`, `total`) and `type Page[T any] struct { Data []T; Meta PageMeta }` (json tags `data`, `meta`), plus `func Paginate[T any](rows []T, page, perPage int, total int64) Page[T]` computing `LastPage` as `ceil(total/perPage)` (guard `perPage<=0` by returning `LastPage:1` instead of dividing by zero) — no `links` field anywhere in the struct (DATA-10, Claude's Discretion). Follow `genre_handler.go`'s "dedicated response struct, never the GORM model" convention: `Page[T]` wraps whatever row-DTO type a caller already built via `.Scan()`, not a GORM model.
Create `lagoon/relations.go` with `func RegisterJoinTable(db *gorm.DB, owner any, field string, joinModel any) error` — a thin, fail-loud wrapper over `db.SetupJoinTable(owner, field, joinModel)` that returns `fmt.Errorf("lagoon: register join table: db is nil")` if `db == nil` before calling through (GORM's own `SetupJoinTable` panics on a nil receiver, which this wrapper avoids). Above it, write a doc comment stating the pivot-write contract other plans must follow: pivot tables with business columns (e.g. `sort_order`, `role`/`granted_at`/`granted_by`) are never written through `db.Model(&owner).Association(field).Append/Replace(...)` — GORM's Association Mode has no documented path to set those columns — writes go through an explicit delete-then-bulk-insert (or `ON CONFLICT DO UPDATE`) function against the join table directly, in the same transaction as the parent save; reads use `Preload(field)` plus `.Order(...)` on the pivot's own columns once `RegisterJoinTable` has run. This task ships the wrapper and the documented contract only — the first real callers (`AlbumArtist`, `CollectionEditor`) and their sync functions are Plan 05-02.
Separately, as its own commit (CLAUDE.md: planning docs and code in separate commits): edit `.planning/REQUIREMENTS.md`'s `DATA-09` bullet to stop stating "27 migrations" as a target — reword to "All 25 Płytarium models and their squashed migration set are ported with matching table names, columns, indexes and defaults (migration count is not itself an acceptance number — squashed per plan-time decision D-01 in 05-CONTEXT.md)". Edit `.planning/ROADMAP.md`'s Phase 5 success criterion 1 to read "...and every Go migration runs up and down individually; the final schema matches PHP's (migrations are squashed per final-state table, not a 1:1 port of PHP's 38 files — the historical migration count is not a target)." Edit ROADMAP.md Phase 5 success criterion 3 to split its HTTP-DTO-fuzz clause out: reword to "The fill boundary of the PHP write services (at minimum Album, Collection and the four credential models) is fuzzed against real Postgres with random extra and server-owned keys, asserting nothing outside the allow-list is persisted (service-level, this phase); a request-DTO-level fuzz over every write endpoint is Phase 12's criterion (the HTTP layer does not exist until Phase 6/12)." Append one clause to Phase 12's existing success-criteria list in ROADMAP.md noting it inherits the endpoint-level fuzz test (do not renumber or otherwise restructure Phase 12's still-TBD plan count).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run 'TestPaginate|TestRegisterJoinTable'</automated>
</verify>
<acceptance_criteria>
- `lagoon.Paginate([]T{a,b,c}, 1, 2, 3)` returns `Meta{CurrentPage:1,LastPage:2,PerPage:2,Total:3}`; `json.Marshal`-ing the result contains no `"links"` substring (grep the encoded bytes in the test).
- `lagoon.RegisterJoinTable(nil, ...)` returns a non-nil, non-panicking error.
- `grep -n "27 migrations" .planning/ROADMAP.md .planning/REQUIREMENTS.md` returns no matches.
- `grep -n "D-01" .planning/REQUIREMENTS.md` shows the reworded DATA-09 line.
- The ROADMAP/REQUIREMENTS wording fix is a commit separate from the `lagoon/paginate.go`+`lagoon/relations.go` code commit.
</acceptance_criteria>
<done>`lagoon.Paginate` and `lagoon.RegisterJoinTable` exist and are tested; ROADMAP.md/REQUIREMENTS.md wording matches D-01/D-07 in a commit separate from the code.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| caller -> lagoon.Fill | untrusted request-shaped map reaches a model's fields via reflection |
| plugin restructure -> auth middleware | must-change-password middleware moves files; a name/registration mistake could silently stop gating the authenticated surface |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Tampering | `lagoon.Fill` | mitigate | Allow-list copy only; unknown/unlisted keys are dropped, never set via reflection, and logged once in non-production (D-05/D-06) |
| T-05-02 | Elevation of Privilege | `middleware.mustChangePassword` after the plugin restructure | mitigate | Task 2's acceptance criteria re-run `TestParityCorpus`, which replays the recorded genres fixture through the full `jwt.auth`+`inv.must-change-password` middleware chain — any registration-name or wiring mistake fails the existing parity fixture, not a new bespoke check |
| T-05-03 | Information Disclosure | `lagoon.HasHidden`/`json:"-"` interfaces declared this plan | accept | No real model implements them yet (first credential model lands in Plan 05-03); the enforcement test (marshal-every-registered-model, assert hidden absent) is Plan 05-06's job once real models exist |
</threat_model>
<verification>
Run in both repos after all four tasks: `cd summercms.go && go vet ./... && go test ./... -short` and `cd ../fonoteka.go && go vet ./... && go test ./... -run TestParityCorpus`. Confirm `git log` shows the ROADMAP/REQUIREMENTS wording fix as a commit distinct from any code commit.
</verification>
<success_criteria>
- The models-leaf rule is confirmed (or amended) against 3 additional real plugins and the folded todo is closed before the restructure runs.
- Both `fonoteka.go` plugins are Winter-directory-shaped with self-registering `models`/`updates`/`classes` packages; `plugin.go` in each never needs editing again by a later plan in this phase.
- `lagoon.Fill`, `lagoon.HasFillable`, `lagoon.HasHidden`, the five lifecycle hook interfaces, `WithSoftDeleteCascade`, `lagoon.Paginate`, and `lagoon.RegisterJoinTable` exist, are tested, and `go vet ./...` is clean in both repos.
- The genres route's recorded parity fixture is unchanged and green.
- ROADMAP.md/REQUIREMENTS.md no longer claim "27 migrations" as a target and note the criterion-3 HTTP-fuzz move to Phase 12, committed separately from code.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md` when done
</output>
</output>