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

31 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-data-layer-full-fidelity 01 execute 1
.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
true
DATA-03
DATA-06
DATA-10
truths artifacts key_links
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)
path provides
summercms.go/lagoon/fill.go Fill(model, allowed, requested, production) + HasFillable/HasHidden interfaces
path provides
summercms.go/lagoon/lifecycle.go GORM-native hook interfaces + WithSoftDeleteCascade
path provides
summercms.go/lagoon/paginate.go Page[T]/PageMeta + Paginate[T]
path provides
summercms.go/lagoon/relations.go RegisterJoinTable wrapper + pivot-write contract doc
path provides
fonoteka.go/plugins/golem15/fonoteka/models/registry.go Register/All so later plans add model files without editing plugin.go
path provides
fonoteka.go/plugins/golem15/fonoteka/updates/registry.go Register/All so later plans add migration files without editing plugin.go
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/plugin.go fonoteka.go/plugins/golem15/fonoteka/models/registry.go Models() returns models.All() func (p *Plugin) Models() []any {\s*return models.All()
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/plugin.go fonoteka.go/plugins/golem15/fonoteka/updates/registry.go Migrations() returns updates.All() func (p *Plugin) Migrations().*{\s*return updates.All()
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From lagoon/order.go (allow-list-then-apply shape to reuse for Fill and RegisterJoinTable):

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):

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):

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):

type HasModels interface { Models() []any }
type HasMigrations interface { Migrations() []*gormigrate.Migration }

From internal/build/leaf.go (the enforcement this plan's restructure must pass):

var modelsSiblingLeaves = []string{"classes", "controllers", "console", "jobs", "middleware", "updates"}
// inspectModelsImports fails if any file under <plugin>/models imports <module>/<sibling>[/...]
Task 1 (.planning): Folded-todo probe — verify the models-leaf rule against three more real plugins; gate the restructure on the result .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) .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/) 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 "\\Classes" /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).
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 ] - `.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. 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. Task 2 (fonoteka.go): Restructure both Phase-3 plugins into the Winter layout with collision-free registries 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 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 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.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestParityCorpus - `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()`. Both plugins are Winter-directory-shaped with self-registering models/updates/classes registries, and the genres parity fixture is unchanged and green. Task 3 (summercms.go): lagoon write-path primitives — Fill/Fillable/Hidden, lifecycle hooks, transactional soft-delete cascade 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/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) - `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. 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:"` 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).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run 'TestFill|TestLifecycle|TestWithSoftDeleteCascade' - `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. `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. Task 4 (summercms.go, .planning): lagoon read-path primitives — pagination envelope, ordered-pivot-read convention; fix DATA-09/criterion-3 wording 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 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) 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).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run 'TestPaginate|TestRegisterJoinTable' - `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. `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.

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

<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>
Create `.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md` when done