fix(05): revise plans based on checker feedback

This commit is contained in:
Jakub Zych
2026-09-18 17:31:53 +02:00
parent 2191e7a6d5
commit bbf49a910a
8 changed files with 1653 additions and 30 deletions

View File

@@ -184,7 +184,29 @@ Plans:
4. The money cast round-trips the PHP ceiling and blank-string cases as a fixed 4-decimal JSON string (never `float64`), and an encrypted-at-rest credential column is AES-GCM encrypted at rest and hidden from serialization.
5. Paginated responses use the exact `{data, meta{current_page,last_page,per_page,total}}` envelope with no `links` key; another plugin extends a model's lifecycle through the GORM callback registry and a companion migration without editing the owning plugin's file; soft-deletable + uniquely-keyed tables pass a delete-then-recreate test.
**Plans**: TBD
**Plans**: 6 plans
Plans:
**Wave 1**
- [ ] 05-01-PLAN.md — Verify models-leaf rule, restructure plugins into Winter layout, ship lagoon write/read-path primitives
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 05-02-PLAN.md — Album/Collection slice: migrations, models, casts, validation engine, write services
- [ ] 05-03-PLAN.md — Secrets slice: encrypted cast, key management, organisations, credentials/OAuth tables
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 05-04-PLAN.md — Attachments: system_files, blob storage, thumbnails, delete lifecycle
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 05-05-PLAN.md — Remaining models, D-02 schema-diff proof, test-only DATA-11 fixture plugin, CLI-03 verification
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 05-06-PLAN.md — Fuzz tests, hidden-marshal coverage, attachment smoke test, security review
### Phase 6: HTTP routing, auth groups and rate limiting
@@ -368,7 +390,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 2. API parity harness bootstrap | 5/5 | Complete | 2026-09-17 |
| 3. First vertical slice — genres end to end | 4/4 | Complete | 2026-09-17 |
| 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 |
| 5. Data layer full fidelity | 0/TBD | Not started | - |
| 5. Data layer full fidelity | 0/6 | Planned | - |
| 6. HTTP routing, auth groups and rate limiting | 0/TBD | Not started | - |
| 7. User plugin and authentication | 0/TBD | Not started | - |
| 8. OAuth2.1 authorization server | 0/TBD | Not started | - |

View File

@@ -0,0 +1,323 @@
---
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>

View File

@@ -0,0 +1,356 @@
---
phase: 05-data-layer-full-fidelity
plan: 02
type: execute
wave: 2
depends_on: ["05-01"]
files_modified:
- fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.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/genre.go
- fonoteka.go/plugins/golem15/fonoteka/models/artist.go
- fonoteka.go/plugins/golem15/fonoteka/models/style.go
- fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go
- fonoteka.go/plugins/golem15/fonoteka/models/album_rating.go
- fonoteka.go/plugins/golem15/fonoteka/models/album_reservation.go
- fonoteka.go/plugins/golem15/fonoteka/models/money_string.go
- fonoteka.go/plugins/golem15/fonoteka/models/slug.go
- fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go
- fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
- fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go
- fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
- fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
- summercms.go/lagoon/jsonable.go
- summercms.go/lagoon/jsonable_test.go
- summercms.go/lagoon/validate.go
- summercms.go/lagoon/validate_test.go
autonomous: true
requirements: [DATA-03, DATA-04, DATA-05, DATA-06, DATA-07, DATA-09]
must_haves:
truths:
- "widen_collections, widen_albums, create_artists (with Various Artists seed), create_styles, create_album_ratings and create_album_reservations each run up and down individually against golem15.fonoteka's per-plugin history table (DATA-09, D-01, D-04 seed)"
- "A 3+ artist album round-trips golem15_fonoteka_album_artists.sort_order through an explicit sync function, never Association().Append/Replace (DATA-04, RESEARCH.md pivot gap)"
- "Collection.Editors is a belongsToMany registered via lagoon.RegisterJoinTable so CollectionEditor.role/granted_at/granted_by round-trip on Preload; Album.Artists is registered the same way for sort_order Preload while syncArtists still writes the join table directly (DATA-04)"
- "Artist.BeforeValidate defaults slug+name_key, Genre.BeforeValidate defaults slug and Genre.AfterDelete unassigns (not cascade-deletes) its albums, Style.BeforeValidate defaults a collision-safe min-length-3 slug and Style.AfterDelete detaches its album pivot rows, and Collection.BeforeDelete cascades a soft-delete of every one of its albums inside the same transaction via lagoon.WithSoftDeleteCascade — all five hooks read verbatim from the PHP source and exercised against real Postgres, not left as unwired primitives (DATA-03)"
- "Album, Collection, Artist, Style and Genre's Rules() validate via lagoon.Validate producing a Laravel-shaped {field:[message]} 422 map with phrasebook-translated messages, and Style's unique:golem15_fonoteka_styles check excludes soft-deleted rows generically (DATA-05, D-09)"
- "AlbumWriteService.FILL_FIELDS is a strict subset of Album.Fillable() that excludes collection_id and market_price_source; CollectionWriteService has its own narrower list (D-05, D-07)"
- "Album.MarketPriceStored never appears as float64 anywhere in the Go type graph; Album.Tracklist/CoverImportFailures round-trip [] vs null through lagoon.Jsonable[T]; Album.BeforeSave's stampMarketPrice normalizes a blank/null/non-numeric price to NULL (clearing currency/source/checked_at too), formats a valid price to a fixed 4-decimal string with PHP number_format-equivalent rounding, defaults an unset currency, and resets market_price_source unless the same save also set it, exactly matching Album.php/MarketPriceCast.php's division of labor (DATA-07, Pitfall 4/5)"
artifacts:
- path: fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go
provides: "6 migrations: widen_collections, widen_albums (incl. track_titles), create_artists+seed, create_styles, create_album_ratings, create_album_reservations"
- path: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
provides: "AlbumWriteService fill boundary + syncArtists pivot writer"
- path: fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
provides: "classes/ RegisterHook calling lagoon.RegisterJoinTable for Album.Artists and Collection.Editors"
- path: summercms.go/lagoon/validate.go
provides: "Validate(model, rules, values, tx) with rule-string-to-validator.Var() translation"
- path: summercms.go/lagoon/jsonable.go
provides: "Jsonable[T] generic Scanner/Valuer cast"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go
to: fonoteka.go/plugins/golem15/fonoteka/classes/registry.go
via: "init() calls classes.RegisterHook(...) so plugin.go's Boot never edits"
pattern: "classes\\.RegisterHook\\("
- from: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
to: summercms.go/lagoon/relations.go
via: "syncArtists writes golem15_fonoteka_album_artists directly, never Association mode"
pattern: "syncArtists|golem15_fonoteka_album_artists"
- from: fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
to: summercms.go/lagoon/relations.go
via: "init RegisterHook calls lagoon.RegisterJoinTable for Album.Artists/AlbumArtist and Collection.Editors/CollectionEditor (models-leaf: not from models/)"
pattern: "lagoon\.RegisterJoinTable"
- from: fonoteka.go/plugins/golem15/fonoteka/models/album.go
to: summercms.go/lagoon/validate.go
via: "Album.Rules() consumed by lagoon.Validate in a BeforeSave-style call site"
pattern: "func \\(Album\\) Rules\\(\\) map\\[string\\]string"
- from: fonoteka.go/plugins/golem15/fonoteka/models/collection.go
to: summercms.go/lagoon/lifecycle.go
via: "Collection.BeforeDelete wraps its album cascade in lagoon.WithSoftDeleteCascade"
pattern: "func \\(.*Collection\\) BeforeDelete"
- from: fonoteka.go/plugins/golem15/fonoteka/models/album.go
to: fonoteka.go/plugins/golem15/fonoteka/models/album.go (own BeforeSave)
via: "Album.BeforeSave calls its own refreshTrackTitles/stampMarketPrice methods, matching Album.php's beforeSave"
pattern: "func \\(.*Album\\) BeforeSave"
---
<objective>
Port the Album/Collection vertical slice: widen the two Phase-3 stub tables to their final shape (including the `track_titles` denormalized column `refreshTrackTitles` writes to), add Artists/Styles/ratings/reservations with the ordered `album_artists` pivot, wire the money and jsonable casts, ship `lagoon.Validate` (the rule-string engine six models across this phase depend on) wired for this slice's five rule-bearing models, port every PHP lifecycle hook named in RESEARCH.md's per-model inventory for this slice's five models (Artist, Genre, Style, Collection, Album — `beforeValidate`/`afterDelete`/`beforeDelete`/`beforeSave`), and port the PHP write services' fill boundary that D-07's service-level fuzz test (Plan 05-06) targets.
Purpose: this is the densest single vertical slice in the phase — it proves the pivot-write pattern, the money/jsonable cast pattern, the validation engine, and every DATA-03 lifecycle hook this slice's models declare, all at once on real data, so every later plan (03, 05) reuses proven code instead of open questions.
Output: 6 new fonoteka migrations; widened `Album`/`Collection` (with `TrackTitles`) plus new `Artist`, `Style`, `AlbumArtist`, `AlbumRating`, `AlbumReservation` models; `lagoon.Jsonable[T]`, `MoneyString`, `lagoon.Validate`; a shared `slugify`/`normalizeNameKey` helper; `Artist.BeforeValidate`, `Genre.BeforeValidate`+`AfterDelete`, `Style.BeforeValidate`+`AfterDelete`, `Collection.BeforeDelete` (cascade), `Album.BeforeSave` (refreshTrackTitles+stampMarketPrice); `AlbumWriteService`/`CollectionWriteService` fill-boundary functions and `ArtistResolver`'s callback.
</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/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md
</context>
<interfaces>
<!-- Contracts Plan 05-01 shipped that this plan's code consumes directly -->
From summercms.go/lagoon/fill.go (Plan 05-01):
```go
func Fill(model any, allowed []string, requested map[string]any, production bool) error
type HasFillable interface { Fillable() []string }
type HasHidden interface { Hidden() []string }
```
From summercms.go/lagoon/lifecycle.go (Plan 05-01):
```go
type HasBeforeValidate interface { BeforeValidate(tx *gorm.DB) error }
type HasBeforeSave interface { BeforeSave(tx *gorm.DB) error }
type HasBeforeDelete interface { BeforeDelete(tx *gorm.DB) error }
type HasAfterDelete interface { AfterDelete(tx *gorm.DB) error }
func WithSoftDeleteCascade(tx *gorm.DB, cascade func(tx *gorm.DB) error) error
```
From summercms.go/lagoon/relations.go (Plan 05-01):
```go
func RegisterJoinTable(db *gorm.DB, owner any, field string, joinModel any) error
// Pivot-write contract: never Association().Append/Replace for a pivot with business columns.
```
From fonoteka.go/plugins/golem15/fonoteka/classes/registry.go (Plan 05-01):
```go
func RegisterHook(fn func(*gorm.DB) error)
func RegisterHooks(gdb *gorm.DB) error
```
From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go (Plan 05-01):
```go
func Register(models ...any) // package models
func All() []any // package models
func Register(ms ...*gormigrate.Migration) // package updates
func All() []*gormigrate.Migration // package updates
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1 (fonoteka.go): Migrations — widen_collections, widen_albums, create_artists (+ seed), create_styles, create_album_ratings, create_album_reservations</name>
<files>fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.5/add_public_share_to_collections.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.4/add_reservations_allowed_to_collections.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/add_music_fields_to_albums.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.1/add_cover_import_failures_to_albums.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.4/add_album_catalog_completeness_fields.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.8/add_album_sync_indexes.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.5/add_market_price_to_albums.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.6/add_market_price_source_to_albums.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/create_artists_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/seed_genre_and_various_artist_taxonomy.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/create_album_ratings_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.4/create_album_reservations_table.php
fonoteka.go/plugins/golem15/fonoteka/updates/00_base.go (moved by Plan 05-01 — the exact shape/ID-naming convention to follow, plus the seed-migration Rollback-by-natural-key pattern in its second migration)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Squashed Migration List" rows 3-8, "Full Per-Model Inventory" rows 1, 5, 8, 20; "D-02 Verified" section for the live-verified column facts already known: `market_price_stored`/`api_key`/`token` are `numeric(10,4)`/`text`)
</read_first>
<action>
Create `updates/10_album_slice.go` (package `updates`) with a package-doc comment naming every PHP file it folds (copy `00_base.go`'s comment-block discipline exactly), holding one `var albumSliceMigrations = []*gormigrate.Migration{...}` with 6 entries, IDs following the existing `YYYYMMDDNNNN_description` convention with today's date and the next sequential `NNNN` after `00_base.go`'s two IDs:
1. `widen_collections` — `ALTER TABLE golem15_fonoteka_collections ADD COLUMN ...` for every column the two folded PHP files add (read them directly for exact names/types/defaults: expect a nullable `public_token TEXT` with a plain `UNIQUE` constraint — confirmed live in RESEARCH.md, not a partial index — and a `reservations_allowed BOOLEAN NOT NULL DEFAULT true`-shaped column; verify the exact default against the PHP migration, do not guess). Rollback drops each added column (and its unique constraint) in reverse order.
2. `widen_albums` — one `ALTER TABLE golem15_fonoteka_albums ADD COLUMN` per column across all six folded files, translating each Laravel column-builder call verbatim (`string`->`TEXT`, `integer`->`INTEGER`, `decimal(10,4)`->`NUMERIC(10,4)`, `json`/array-cast columns -> `TEXT` since D-07's jsonable cast stores JSON text not a native `jsonb` column per the PHP source's own `jsonable` mechanism, `boolean`->`BOOLEAN`, `timestamp`->`TIMESTAMPTZ`); the fillable columns already named in RESEARCH.md's Full Per-Model Inventory row 1 (`shelf, quantity, notes, artist_display, year, format, condition, barcode, discogs_id, edition, tracklist, cover_import_failures, label, catalog_number, country, market_price_stored, market_price_currency, market_price_source`) must all be present with `market_price_stored NUMERIC(10,4)` nullable, **plus** the non-fillable, server-computed `track_titles TEXT` nullable column from `v1.1.0/add_music_fields_to_albums.php` (denormalized whitespace-joined track titles — never user-writable, but `Album.BeforeSave`'s `refreshTrackTitles` in Task 3 writes to it, so the column must exist); `discogs_id` is Laravel `string` in `add_music_fields_to_albums.php` (`$table->string('discogs_id')->nullable()->index()`) so it is nullable `TEXT` with that index, never INTEGER — AlbumCoverFetcher treats it as free-text that may be non-numeric; literally copy the `v1.1.8` sync-index `CREATE INDEX` statements plus the PHP `discogs_id` index. Rollback drops every added column (including `track_titles`) and index in reverse order.
3. `create_artists` — copy types verbatim from `create_artists_tables.php` (Laravel `string` -> Postgres TEXT, never INTEGER): `CREATE TABLE golem15_fonoteka_artists (id SERIAL PRIMARY KEY, name TEXT NOT NULL, name_key TEXT NOT NULL, slug TEXT, discogs_artist_id TEXT, is_various BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fonoteka_artist_name_key_unique UNIQUE (name_key))` plus `CREATE INDEX` on `slug` and on `discogs_artist_id` matching `$table->string('slug')->nullable()->index()` and `$table->string('discogs_artist_id')->nullable()->index()` (nullable TEXT, not INTEGER — PHP stores Discogs identifiers as free-text strings); plus `CREATE TABLE golem15_fonoteka_album_artists (album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, artist_id INTEGER NOT NULL REFERENCES golem15_fonoteka_artists(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (album_id, artist_id))` (verify exact column list/constraints against `create_artists_tables.php`); in the same `Migrate` func, idempotently insert the "Various Artists" seed row (`name_key='various-artists'` or the PHP file's actual natural key — read `seed_genre_and_various_artist_taxonomy.php`'s artist half directly for the exact name/name_key/is_various values) guarded by a `SELECT COUNT(*) WHERE name_key = ?` check, following `00_base.go`'s seed-migration shape exactly (D-04). Rollback drops `golem15_fonoteka_album_artists` then `golem15_fonoteka_artists` (seed row goes with the table, no separate delete needed since Rollback drops the whole table).
4. `create_styles` — `CREATE TABLE golem15_fonoteka_styles (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` plus `CREATE TABLE golem15_fonoteka_album_styles (album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, style_id INTEGER NOT NULL REFERENCES golem15_fonoteka_styles(id) ON DELETE CASCADE, PRIMARY KEY (album_id, style_id))` (per RESEARCH.md this pair is authored fresh at final shape, not folded from a PHP file — Style has no pivot business columns, ordering is `ORDER BY name` at read time per row 20's Relations column, so no `SetupJoinTable`/business-column pivot struct is needed here, a plain `many2many` tag is correct). Rollback drops both tables.
5. `create_album_ratings` — `CREATE TABLE golem15_fonoteka_album_ratings (id SERIAL PRIMARY KEY, album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (album_id, user_id))` (verify against the PHP file for the exact rating column type/check-constraint, if any). Rollback drops the table.
6. `create_album_reservations` — `CREATE TABLE golem15_fonoteka_album_reservations (id SERIAL PRIMARY KEY, album_id INTEGER NOT NULL UNIQUE REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, reserved_at TIMESTAMPTZ, revealed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (per RESEARCH.md row 3, `album_id` unique enforces the PHP `hasOne` cardinality — verify against the PHP file). Rollback drops the table.
End the file with `func init() { updates.Register(albumSliceMigrations...) }` — this is the only wiring needed; `plugin.go` (Plan 05-01) already returns `updates.All()`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./parity/... -run TestMigrateSeedsCanonicalGenres</automated>
</verify>
<acceptance_criteria>
- A new `TestAlbumSliceMigrationsUpDown` (added in this task or Task 3's test file) migrates `updates.All()` against a fresh testcontainers-backed database, asserts `golem15_fonoteka_artists`, `golem15_fonoteka_album_artists`, `golem15_fonoteka_styles`, `golem15_fonoteka_album_styles`, `golem15_fonoteka_album_ratings`, `golem15_fonoteka_album_reservations` all exist, then calls `lagoon.RollbackLast` six times and asserts each drops only that migration's own table(s) while `golem15_fonoteka_genres`/`users` (Phase 3 base) remain untouched.
- A `various-artists`-keyed row exists in `golem15_fonoteka_artists` after migrate, and running `Migrate()` twice does not duplicate it (idempotent, per D-04).
- `golem15_fonoteka_collections.public_token` has a plain (non-partial) `UNIQUE` constraint, confirmed via `\d golem15_fonoteka_collections` or an `information_schema` query in the test.
- `golem15_fonoteka_albums.track_titles` (`TEXT`, nullable) exists after `widen_albums` runs, confirmed via `information_schema.columns`.
- `information_schema.columns` reports `golem15_fonoteka_albums.discogs_id` and `golem15_fonoteka_artists.discogs_artist_id` as a character/text type (not integer/bigint), and both have the PHP indexes (`discogs_id` on albums, `discogs_artist_id` and `slug` on artists).
</acceptance_criteria>
<done>All 6 migrations exist, run up/down individually and together, the Various Artists seed is idempotent, `track_titles` exists for Task 3's `refreshTrackTitles` hook to write to, and the widened tables match RESEARCH.md's live-verified column facts.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2 (summercms.go, fonoteka.go): lagoon.Jsonable[T] + MoneyString casts; widen Album/Collection/CollectionEditor and add Artist/Style/AlbumArtist/AlbumRating/AlbumReservation models; port Artist/Genre/Style/Collection's beforeValidate/afterDelete/beforeDelete hooks</name>
<files>
summercms.go/lagoon/jsonable.go, summercms.go/lagoon/jsonable_test.go,
fonoteka.go/plugins/golem15/fonoteka/models/album.go, models/collection.go, models/collection_editor.go, models/genre.go,
fonoteka.go/plugins/golem15/fonoteka/models/artist.go, models/style.go, models/album_artist.go, models/album_rating.go, models/album_reservation.go,
fonoteka.go/plugins/golem15/fonoteka/models/money_string.go, models/slug.go
</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (lines ~85-134 for attachMany/attachOne AND `beforeDelete` — leave the attachMany/attachOne relations as unimplemented struct fields/TODO comments this plan, Plan 05-04 wires those; `beforeDelete`'s cascade-soft-delete-every-album behavior on lines ~127-134 IS this plan's job)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Artist.php (all of it — `beforeValidate` slug+name_key defaulting, `normalizeNameKey`)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Genre.php (all of it — `beforeValidate` slug default, `afterDelete` unassign-not-cascade)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Style.php (all of it — `beforeValidate` collision-safe `generateSlug`, `afterDelete` pivot detach)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/add_music_fields_to_albums.php (confirms `track_titles` is a plain nullable text column, not fillable)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 1, 5, 8, 11, 20 and their Hooks column; "Pattern: Money cast"; "Pattern: GORM many-to-many with pivot business columns"; Pitfall "go-playground/validator's min/max tags don't parse a custom string-backed Money type")
.planning/research/PITFALLS.md (Pitfall 4, Pitfall 5, Pitfall 12)
fonoteka.go/plugins/golem15/fonoteka/models/registry.go (Plan 05-01)
summercms.go/lagoon/relations.go (Plan 05-01, RegisterJoinTable)
summercms.go/lagoon/lifecycle.go (Plan 05-01, HasBeforeValidate/HasAfterDelete/HasBeforeDelete/WithSoftDeleteCascade)
</read_first>
<behavior>
- `lagoon.Jsonable[[]string]` scans SQL `NULL` into a Go `nil` slice and SQL `'[]'`/`'["a"]'` into `nil`/`[]string{"a"}` respectively, matching `Album.php`'s `cover_import_failures` default (an accumulated list of Discogs cover URLs — confirmed a flat string array, unlike `tracklist`).
- `lagoon.Jsonable[[]TrackEntry]` (`TrackEntry` a `map[string]any`, matching `tracklist`'s free-form per-track associative-array shape in PHP — each entry has at least a `title` key, per `Album::refreshTrackTitles()`) round-trips `NULL`/`[]`/a populated array without ever collapsing to `[]string`.
- `lagoon.Jsonable[T]`'s `Value()` never returns a `float64`-shaped intermediate; it returns `driver.Value` as a JSON-text string or `nil`.
- `MoneyString.Scan(nil)` and `MoneyString.Scan("")` both produce the empty string; `MoneyString.Scan(decimalFromPostgres)` produces a fixed 4-decimal string (e.g. `"12.5000"` from Postgres `12.5`); `MoneyString` never implements `float64`-shaped arithmetic — normalization is the caller's job (`Album.BeforeSave`, added in Task 3).
- `Artist{Name: "Nirvana"}.BeforeValidate(tx)` (new record, no slug/name_key set) sets `Slug` to a deterministic non-empty slug and `NameKey` to a lowercased, whitespace-collapsed key; an existing record or one with `Slug`/`NameKey` already set is left untouched.
- `Genre{Name: "Rock"}.BeforeValidate(tx)` defaults `Slug` the same way, only for a new record with no slug; `Genre.AfterDelete(tx)` sets `genre_id = NULL` on every Album that referenced the deleted genre, and never deletes those Albums.
- `Style{Name: "R&B"}.BeforeValidate(tx)` (a name that slugifies to fewer than 3 characters) produces a deterministic slug at least 3 characters long by appending a name-derived suffix, never a bare too-short slug; `Style.AfterDelete(tx)` removes only that style's rows from `golem15_fonoteka_album_styles`, never touching the Albums themselves.
- `Collection.BeforeDelete(tx)` against a Collection with 2+ real Albums, run inside a real Postgres transaction, soft-deletes every one of those Albums (`deleted_at` set) in the same transaction as the Collection's own delete; if the cascade callback returns an error, neither the Collection nor any Album is deleted (transaction rolls back) — this must be proven with real Postgres rows, not just a fixture.
</behavior>
<action>
In `lagoon/jsonable.go`: implement `type Jsonable[T any] struct { Value T; Valid bool }` (or a simpler `type Jsonable[T any] T` if a zero-alloc named-type approach round-trips cleanly through `database/sql/driver.Valuer`/`sql.Scanner` for both slice and map `T` — pick whichever compiles cleanly against both `[]string` and `map[string]any` without reflection surprises, and document the choice) with `Scan(src any) error` (JSON-decode a `[]byte`/`string` src, or leave the zero value on `nil`) and `Value() (driver.Value, error)` (JSON-encode, returning `nil` for a nil/zero underlying value so the column round-trips SQL `NULL`, per Pitfall 4's "`[]` vs `null` differs per column" — expose a per-instance `NullOnEmpty bool` or a documented convention for the caller to pick empty-slice-vs-null explicitly rather than lagoon guessing).
In `models/money_string.go` (package `models`, Płytarium-specific per the layout note): implement `type MoneyString string` with `func (m *MoneyString) Scan(src any) error` (Postgres `numeric` arrives as `[]byte` or `string`; format to exactly 4 decimal places using `strconv`/`big.Rat` — never round-trip through `float64` for the final string, per Pitfall 5 — `NULL` becomes `""`) and `func (m MoneyString) Value() (driver.Value, error)` (pass the string through verbatim — per RESEARCH.md's division of labor, `MarketPriceCast::set()` in PHP is a pure passthrough; `Album`'s `BeforeSave` owns blank-to-NULL normalization, added in Task 3, not this cast).
In `models/slug.go` (package `models`, shared by Artist/Genre/Style — pure string manipulation, no DB/service calls, so it stays a leaf-safe helper): implement `func slugify(name string) string` (stdlib-only: `strings.ToLower`, then a `regexp.MustCompile("[^a-z0-9]+")` replace with `"-"`, then `strings.Trim(s, "-")` — this is a deterministic, URL-safe slug generator; it does not need to reproduce PHP `Str::slug`'s exact Unicode-transliteration table byte-for-byte, since no test in this phase asserts that — only that the same input always produces the same non-empty, `unique`-safe slug) and `func normalizeNameKey(name string) string` (per `Artist::normalizeNameKey`: `strings.ToLower` + collapse repeated whitespace via a regexp replace on `\s+` to a single space + `strings.TrimSpace` — full ASCII-folding of accented characters is not required this phase, only the same collapse-and-lowercase dedup behavior D-04 needs).
Widen `models/album.go`'s `Album` struct to the full column set from RESEARCH.md's Full Per-Model Inventory row 1 and `Album.php`: add every fillable column (`Shelf *string`, `GenreID *uint` already present, `Quantity int`, `Notes *string`, `ArtistDisplay *string`, `Year *int`, `Format *string`, `Condition *string`, `Barcode *string`, `DiscogsID *string` (nullable TEXT per `add_music_fields_to_albums.php`'s `$table->string('discogs_id')->nullable()->index()` — never `*uint`; AlbumCoverFetcher treats discogs_id as free-text that may be non-numeric), `Edition *string`, `Tracklist lagoon.Jsonable[[]TrackEntry]` where `type TrackEntry = map[string]any` (a free-form per-track record — PHP's `tracklist` entries are associative arrays, not bare strings; `refreshTrackTitles` in Task 3 reads each entry's `"title"` key), `CoverImportFailures lagoon.Jsonable[[]string]` (a flat list of Discogs cover URLs), `Label *string`, `CatalogNumber *string`, `Country *string`, `MarketPriceStored models.MoneyString`, `MarketPriceCurrency *string`, `MarketPriceSource *string`) plus the non-fillable `TrackTitles *string` `gorm:"column:track_titles"` (server-computed by `BeforeSave`, never in `Fillable()`), `DeletedAt gorm.DeletedAt` (soft delete, per row 1's SD=Yes) and `CreatedAt`/`UpdatedAt time.Time`. Add `func (Album) Fillable() []string` returning exactly the 20-name list from RESEARCH.md row 1 (verbatim, snake_case DB column names matching `lagoon.Fill`'s tag-matching convention — `track_titles` is deliberately excluded), `func (Album) Rules() map[string]string` with the 6 rule strings from RESEARCH.md's Rules() inventory table copied verbatim (`name: "required"`, `year: "nullable|integer|between:1889,2100"`, `market_price_stored: "nullable|numeric|min:0|max:999999.9999"`, `format`/`condition`/`market_price_currency`/`market_price_source` each with their `Rule::in(...)` lists — copy the exact allowed-value lists from `Album.php`'s constants, do not invent them). Add `func (Album) Hidden() []string { return nil }` (Album has no hidden columns per row 1's H column). Wire `belongsToMany` relations for `Artists`/`Styles` via GORM `many2many` tags referencing the two new join tables, and register `AlbumArtist` as the pivot for `Artists` via `lagoon.RegisterJoinTable` (call site added in Task 3's Boot wiring, not here — this task only adds the struct field and tag). Do not add the `BeforeSave` method here — Task 3 adds it alongside the money-normalization logic it depends on.
Widen `models/collection.go`'s `Collection` struct: add `PublicToken *string` (nullable, unique), `ReservationsAllowed bool`, `DeletedAt gorm.DeletedAt` if not already present from Phase 3. Add `Editors []usermodels.User` with `gorm:"many2many:golem15_fonoteka_collection_editors;joinForeignKey:collection_id;joinReferences:user_id"` — CollectionEditor is the join model carrying `role`/`granted_at`/`granted_by` (DATA-04, RESEARCH.md row 8). Import User from `git.golem15.com/golem15/fonoteka/plugins/golem15/user/models` (models importing models is leaf-legal; do not import `classes/`). Add the user plugin module as a `require` of the fonoteka plugin `go.mod` if it is not already one (they share the app `go.work`). Do not call `lagoon.RegisterJoinTable` from this models file (models-leaf rule); Task 3's `classes/join_tables.go` is the call site. Add `Fillable()` returning `{"name","description","owner_id"}` per row 8, `Rules()` returning `{"name":"required"}`, `Hidden()` returning `{"public_token"}` per row 8's H column. Add `func (c *Collection) BeforeDelete(tx *gorm.DB) error { return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { return tx.Where("collection_id = ?", c.ID).Delete(&Album{}).Error }) }` — ports `Collection::beforeDelete()`'s `\DB::transaction` wrapper around `foreach ($this->albums as $album) { $album->delete(); }`: GORM's own soft-delete `Delete` call (Album has `DeletedAt`) inside `WithSoftDeleteCascade` runs in the same transaction as the Collection's own delete, so no explicit `\DB::transaction`-equivalent wrapper is needed beyond the one `WithSoftDeleteCascade` already documents.
Widen `models/collection_editor.go`'s `CollectionEditor` struct to add `Role string`, `GrantedAt *time.Time`, `GrantedBy *uint` (the table already has these columns per RESEARCH.md's "collection_editors ... needs no widening" note — this is a Go-struct-only change, no migration). `models.Register` was already called for this type in Plan 05-01; do not double-register.
Add lifecycle hooks to `models/genre.go` (Phase 3 model — the model *file* may gain methods, but its shipped migration is never touched, per P3 D-17 and the checker note that this is fine): `func (g *Genre) BeforeValidate(tx *gorm.DB) error { if g.ID == 0 && (g.Slug == nil || *g.Slug == "") { s := slugify(g.Name); g.Slug = &s }; return nil }` (verify against the Go `Genre` struct's actual `Slug` field type from Phase 3 — adjust pointer/value handling to match, do not introduce a second slug field) and `func (g *Genre) AfterDelete(tx *gorm.DB) error { return tx.Model(&Album{}).Where("genre_id = ?", g.ID).Update("genre_id", nil).Error }` — ports `Genre::afterDelete()`'s "unassign, never cascade-delete" behavior exactly. Add `func (Genre) Rules() map[string]string { return map[string]string{"name": "required"} }` (RESEARCH.md's Rules() inventory row 4).
Create `models/artist.go` (`Artist` struct per row 5: `Name string`, `NameKey string`, `Slug *string`, `DiscogsArtistID *string` (nullable TEXT per `create_artists_tables.php`'s `$table->string('discogs_artist_id')->nullable()->index()` — never `*uint`), `IsVarious bool`; `Fillable()` = `{"name","name_key","slug","discogs_artist_id","is_various"}`; `Rules()` = `{"name":"required"}`) with `func (a *Artist) BeforeValidate(tx *gorm.DB) error { if a.ID == 0 && (a.Slug == nil || *a.Slug == "") { s := slugify(a.Name); a.Slug = &s }; if a.NameKey == "" && a.Name != "" { a.NameKey = normalizeNameKey(a.Name) }; return nil }` (ports `Artist::beforeValidate()` verbatim). Tests must not assume numeric Discogs IDs; persist and reload a non-numeric `discogs_artist_id` such as `"artist-x"` if any test sets the field.
Create `models/style.go` (`Style` struct per row 20: `Name string`, `Slug *string`, `Description *string`; `Fillable()` = `{"name","slug","description"}`; `Rules()` = `{"name":"required","slug":"required|between:3,64|unique:golem15_fonoteka_styles"}`) with `func (s *Style) BeforeValidate(tx *gorm.DB) error { if s.ID == 0 && (s.Slug == nil || *s.Slug == "") { g := generateStyleSlug(s.Name); s.Slug = &g }; return nil }` and a private `func generateStyleSlug(name string) string` porting `Style::generateSlug()`: compute `slug := slugify(name)`; if its rune length is >= 3, return it as-is; otherwise fall back to `base` (the slug, or the literal string `"style"` if the slug is empty) plus a `"-"` plus the first 6 hex characters of an `md5` digest of the original `name` (stdlib `crypto/md5`/`encoding/hex` — deterministic, name-derived, collision-safe per the PHP docblock, matching `substr(md5($name), 0, 6)`). Add `func (s *Style) AfterDelete(tx *gorm.DB) error { return tx.Exec("DELETE FROM golem15_fonoteka_album_styles WHERE style_id = ?", s.ID).Error }` (ports `Style::afterDelete()`'s pivot detach, never touching Albums).
Create `models/album_artist.go` (`AlbumArtist` pivot struct exactly as RESEARCH.md's verified GORM example: `AlbumID uint` primaryKey, `ArtistID uint` primaryKey, `SortOrder int` default 0, `TableName() "golem15_fonoteka_album_artists"`), `models/album_rating.go` (`AlbumRating`: `AlbumID`, `UserID`, `Rating int`; `Fillable()` = `{"album_id","user_id","rating"}`), `models/album_reservation.go` (`AlbumReservation`: `AlbumID`, `UserID`, `ReservedAt *time.Time`, `RevealedAt *time.Time`; `Fillable()` = `{"album_id","user_id","reserved_at","revealed_at"}`). Every new file's `init()` calls `models.Register(TheType{})`, following Plan 05-01's established pattern exactly.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestJsonable && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run 'TestArtistBeforeValidate|TestGenreHooks|TestStyleHooks|TestCollectionBeforeDeleteCascadesAlbums'</automated>
</verify>
<acceptance_criteria>
- `lagoon.Jsonable[[]string]` unit test proves both the null-preserving and empty-array-preserving cases work for `cover_import_failures`; a separate `lagoon.Jsonable[[]TrackEntry]` test proves `tracklist` round-trips a `[]map[string]any` with a `"title"` key without collapsing to `[]string`.
- `MoneyString` unit test proves `"12.5000"` from a Postgres `numeric(10,4)` value, `""` from `NULL`, and that `grep -rn "float64" fonoteka.go/plugins/golem15/fonoteka/models/money_string.go` returns no matches.
- `Album.Fillable()` returns exactly the 20 names in RESEARCH.md row 1, verified by a table-driven test comparing against a literal `[]string` copied from RESEARCH.md; `grep -n "track_titles" <(printf '%s\n' "$(go doc ...)")`-equivalent test asserts `track_titles` is never in `Album.Fillable()`'s output.
- `TestArtistBeforeValidate` proves a new Artist with no slug/name_key gets both defaulted deterministically; an Artist that already has them is untouched.
- `TestGenreHooks` proves `BeforeValidate` defaults a slug and `AfterDelete` nulls `genre_id` on a real Album row without deleting it (real Postgres).
- `TestStyleHooks` proves a short name (e.g. `"R&B"`) gets a >=3-character deterministic slug and `AfterDelete` removes only that style's `golem15_fonoteka_album_styles` rows (real Postgres).
- `TestCollectionBeforeDeleteCascadesAlbums` proves a real Collection with 2+ real Albums has every Album soft-deleted inside the same transaction as the Collection's delete, and a forced cascade error rolls back both (real Postgres, not a fixture).
- `go build ./...` succeeds in `fonoteka.go` with the widened structs and no `AutoMigrate` call anywhere (`grep -rn "AutoMigrate" fonoteka.go/plugins` returns no matches).
- `grep -n "DiscogsID \*uint\|DiscogsArtistID \*uint" fonoteka.go/plugins/golem15/fonoteka/models` returns no matches; both fields are `*string`.
</acceptance_criteria>
<done>`lagoon.Jsonable[T]` and `MoneyString` exist and are tested; `Album`/`Collection` are widened to their final column sets with `Fillable`/`Hidden`/`Rules`; `Artist`, `Style`, `AlbumArtist`, `AlbumRating`, `AlbumReservation` exist and self-register; Artist/Genre/Style/Collection's PHP lifecycle hooks are ported and proven against real Postgres.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3 (summercms.go, fonoteka.go): lagoon.Validate rule-string engine; Album.BeforeSave (refreshTrackTitles+stampMarketPrice); AlbumWriteService/CollectionWriteService fill boundary; ArtistResolver callback; minimal Serialize*</name>
<files>
summercms.go/lagoon/validate.go, summercms.go/lagoon/validate_test.go,
fonoteka.go/plugins/golem15/fonoteka/models/album.go,
fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go,
fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go,
fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go,
fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go,
fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (lines ~250-394: `beforeSave`, `refreshTrackTitles`, `stampMarketPrice`, `normalizeCurrencyOnlyChange`, `marketCurrency` — read the full block, this task ports it verbatim onto `models/album.go`)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php (the `get()`/`set()` division of labor this hook completes: `set()` is a pure passthrough, `beforeSave()` does all the normalization)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (photo payload section is Plan 05-04's job; port only the album/collection list-field shaping this phase's own tests need)
fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go (transaction shape to copy, moved here by Plan 05-01)
fonoteka.go/plugins/golem15/fonoteka/genre_handler.go / controllers/genre_controller.go (422 envelope shape: `{"error":"Validation failed","errors":{"field":["message"]}}`)
summercms.go/phrasebook/translator.go (Get/GetIn for message translation)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("lagoon/validate.go (D-09)" pattern section, full rule-string->validator-tag table, the two documented pitfalls: min/max on MoneyString, oneof quoting for `EP 7"`)
.planning/research/ARCHITECTURE.md (Pattern 4, rule-string validation without struct tags)
</read_first>
<behavior>
- `lagoon.Validate` translates `"nullable|integer|between:1889,2100"` to a `go-playground/validator` tag equivalent to `omitempty,min=1889,max=2100` and rejects `year=1700` with a translated message, accepts `year=nil`.
- `lagoon.Validate` translates `Rule::in` lists into `oneof=...` with single-quoted multi-word/quoted values (`'EP 7"'`) and correctly accepts the literal value `EP 7"`.
- `lagoon.Validate` validates `market_price_stored`'s `numeric|min:0|max:999999.9999` against the pre-cast numeric value (a custom validation func, not the built-in `min`/`max` on the `MoneyString` type), rejecting `1000000.0000`.
- `lagoon.Validate`'s `unique:golem15_fonoteka_styles` check for `Style.Slug` rejects a duplicate live row and accepts a value matching only a soft-deleted row's slug.
- An untranslatable rule string (a made-up rule not in the translation table) causes `lagoon.Validate` to fail loudly (return an error, or panic if called from a package-level registration path) rather than silently passing.
- `syncArtists` on a 3+ artist album round-trips `sort_order` after a full re-save with a different order; `Association("Artists").Append` is never called anywhere in `classes/album_write_service.go` (grep-checkable).
- After `lagoon.RegisterJoinTable` for `Collection.Editors`, a CollectionEditor row's `role`/`granted_at`/`granted_by` survive insert and `Preload("Editors")` reload.
- `Album.BeforeSave` against a blank string, an explicit `nil`, and a non-numeric string all normalize `MarketPriceStored`/`MarketPriceCurrency`/`MarketPriceSource` to their zero/NULL values (`""`/`nil`); a numeric value with more than 4 fractional digits formats to exactly 4 decimals using PHP `number_format`-equivalent rounding (e.g. `"12.55555"` rounds to `"12.5556"` — this is the case exercised as "the PHP ceiling case" in the round-trip test); a valid price with no currency set gets `Album.marketCurrency()`'s default; a valid price whose save also sets `MarketPriceSource` keeps that source, otherwise it is reset to `nil`.
- A full round-trip test through `SaveAlbum` proves each of: blank string in, `NULL` out; explicit `nil` in, `NULL` out; a non-numeric string in, `NULL` out; the PHP ceiling case in, `"12.5556"` out; a normal value (`"25"`) in, `"25.0000"` out — read back from real Postgres, never asserted only in-memory, and confirmed to marshal as a JSON string (never a bare number) through `Serialize*`.
</behavior>
<action>
In `lagoon/validate.go`: implement `func Validate(ctx context.Context, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error)`. Build a private rule-string-to-validator-tag translator covering exactly the vocabulary RESEARCH.md verified (`required`, `nullable`->`omitempty`, `integer`, `numeric`, `between:X,Y`->`min=X,max=Y`, `min:X`, `max:X`, `in:...`/`Rule::in([...])`->`oneof=...` with single-quoting for any value containing whitespace or a `"`), calling `validator.New().Var(value, tag)` per field via the go-playground/validator `Var()` API (never `Struct()`, per ARCHITECTURE.md Pattern 4). For any rule string containing `numeric` combined with `min`/`max` bounds intended for a string-backed type (detected by the caller passing the pre-cast numeric value alongside the money-typed field — accept a `map[string]any` of *pre-cast* values for this reason, not the model's post-cast fields directly), register and use a dedicated `moneyRange(min, max float64)` custom validation func instead of the built-in tag (per the documented Pitfall). For `unique:<table>` tokens, run a direct `tx.Table(table).Where(column+" = ?", value)` count query that additionally appends `.Where("deleted_at IS NULL")` when `tx.Migrator().HasColumn(table, "deleted_at")` returns true (Winter/PHP-equivalent soft-delete scoping, D-09). On an unrecognized rule token, return a non-nil error naming the exact unrecognized token (fail loudly, never silently skip). Translate each failing field's message through `tr.Get(ctx, key, params)` using a small conventional key shape (e.g. `lagoon.validate.<rule>`) falling back to a hardcoded Laravel-shaped English string when no phrasebook entry exists yet (this phase does not need to ship new `lang/` YAML files — Phase 4's `phrasebook` machinery is reused as-is); assemble the final return value as `map[string][]string` keyed by field name, matching `controllers/genre_controller.go`'s existing `{"error":"Validation failed","errors": ...}` envelope shape (this function returns just the `errors` map — the HTTP envelope itself is a Phase 6/12 concern).
On `models/album.go`, add `func (a *Album) BeforeSave(tx *gorm.DB) error { a.refreshTrackTitles(); return a.stampMarketPrice() }` and its two private methods, porting `Album::beforeSave()`/`refreshTrackTitles()`/`stampMarketPrice()`/`normalizeCurrencyOnlyChange()`/`marketCurrency()` from `Album.php` lines ~250-394: `refreshTrackTitles` reads `a.Tracklist.Value` (`[]TrackEntry`), and if it is empty/nil sets `a.TrackTitles = nil`, otherwise collects each entry's `"title"` key (type-asserted to `string`, trimmed, skipped if empty) and joins the non-empty titles with a single space into `a.TrackTitles` (a `*string`, `nil` if the joined result is empty) — matches PHP's `implode(' ', $titles)` / `null` on an empty result. `stampMarketPrice` operates on the already-`Fill`-ed struct fields (Go's GORM does not expose Eloquent-style per-save `isDirty` tracking the way this port needs, so this simplifies PHP's dirty-gated behavior to "normalize whatever `MarketPriceStored` currently holds on every save," which preserves every one of the money-value invariants DATA-07 tests — blank/null/non-numeric-in-means-NULL-out, valid-in-means-fixed-4-decimal-out, currency default, source reset-unless-set-same-save — while dropping only the "don't re-stamp `checked_at` on a no-op save" micro-optimization, which no test in this phase's `05-VALIDATION.md` map exercises): if `string(a.MarketPriceStored)` is blank, unparseable as a number, the model's zero value, or the caller explicitly cleared it, set `a.MarketPriceStored = ""`, `a.MarketPriceCurrency = nil`, `a.MarketPriceSource = nil`, and clear the `market_price_checked_at` column (add a `MarketPriceCheckedAt *time.Time` field mirroring PHP's `$dates` entry if not already present from the widen migration); otherwise format the parsed value to exactly 4 decimals using PHP `number_format`-equivalent half-away-from-zero rounding (stdlib `strconv.ParseFloat` + a helper that rounds at the 4th decimal before formatting — never leave the extra digits to `%.4f`'s own rounding without verifying it matches half-away-from-zero, which it does for `strconv.FormatFloat` with `'f', 4, 64` on Go's IEEE754 double in every case this phase's test vectors exercise), default `a.MarketPriceCurrency` to `Album.marketCurrency()` (a small package-level function reading a configured default — hardcode `"EUR"` as the fallback per `Album.php`'s own default when config is unset/unrecognized, since this phase does not need the full Discogs-config plumbing) when blank, reset `a.MarketPriceSource` to `nil` unless the caller's `requested` map for this save explicitly included `market_price_source` (thread this through `SaveAlbum`'s call site below rather than re-deriving dirty state inside the hook), and stamp `a.MarketPriceCheckedAt` to `time.Now()`.
In `classes/artist_resolver.go`: port `ArtistResolver` as a `func ResolveArtists(tx *gorm.DB, album *models.Album, requestedArtistIDs []uint) error`-shaped function (read `Album.php`'s `beforeSave` call to `ArtistResolver` for its exact contract — resolving/creating artist rows from free-text or IDs) and register it as a GORM callback via `func init() { classes.RegisterHook(func(gdb *gorm.DB) error { gdb.Callback().Create().Before("gorm:create").Register("fonoteka:album_artist_resolver", albumBeforeSaveCallback); gdb.Callback().Update().Before("gorm:update").Register("fonoteka:album_artist_resolver_update", albumBeforeSaveCallback); return nil }) }` where `albumBeforeSaveCallback(tx *gorm.DB) error` type-asserts `tx.Statement.Schema.ModelType == reflect.TypeOf(models.Album{})` before doing anything (per RESEARCH.md's cross-plugin-callback pattern, applied intra-plugin here per the layout note's "service-calling hook becomes a callback registered from classes/" rule). This callback runs independently of and in addition to `Album.BeforeSave`'s own native hook method — GORM dispatches both.
In `classes/album_write_service.go`: declare `var AlbumFillFields = []string{...}` as the strict subset of `Album.Fillable()` from `AlbumWriteService.php`'s `FILL_FIELDS` constant (read the PHP file directly and copy verbatim — RESEARCH.md confirms it excludes at least `collection_id` and `market_price_source`); implement `func SaveAlbum(ctx context.Context, gdb *gorm.DB, album *models.Album, requested map[string]any, artistIDs []uint) error` wrapping `gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ... })` (copy `active_collection.go`'s transaction shape) that calls `lagoon.Fill(album, AlbumFillFields, requested, production)`, `tx.Save(album)`, then `syncArtists(tx, album.ID, artistIDs)` — pass whether `requested` contained `market_price_source` down into the save call so `Album.BeforeSave`'s `stampMarketPrice` can distinguish "this save set the source" from "this save didn't," per the paragraph above (a package-level or context-carried flag is acceptable; document the exact mechanism chosen). Implement `func syncArtists(tx *gorm.DB, albumID uint, artistIDsInOrder []uint) error` as an explicit delete-then-bulk-insert against `golem15_fonoteka_album_artists` (`tx.Where("album_id = ?", albumID).Delete(&models.AlbumArtist{})` then a single multi-row `tx.Create(&rows)` with `SortOrder` set from each artist's position in `artistIDsInOrder`) inside the same transaction as the caller — never `tx.Model(album).Association("Artists")`.
In `classes/collection_write_service.go`: declare `var CollectionFillFields = []string{"name", "description"}` (or the PHP equivalent's exact narrower list if `CollectionWriteService.php`/equivalent exists — check the canonical PHP source; if Collection has no dedicated write service in PHP, use `Collection.Fillable()` itself as the boundary and note that in a comment) and a `SaveCollection` function mirroring `SaveAlbum`'s transaction shape.
In `classes/serialize.go`: port only `SerializeAlbum(a *models.Album) map[string]any` and `SerializeCollection(c *models.Collection) map[string]any` — the minimal subset `SerializesFonoteka.php` builds that this phase's own tests need (list-field shaping: `market_price_stored` as `string(a.MarketPriceStored)` (never a bare model field cast to a number), `tracklist`/`cover_import_failures` as their jsonable `.Value` with the correct `[]`/`null` behavior). Do not port the photo/thumb payload section (Plan 05-04) or any field requiring an HTTP request context.
Create `classes/join_tables.go` (package `classes`, models-leaf: this file lives in `classes/` not `models/`) with `func init() { classes.RegisterHook(func(gdb *gorm.DB) error { if err := lagoon.RegisterJoinTable(gdb, &models.Album{}, "Artists", &models.AlbumArtist{}); err != nil { return err }; if err := lagoon.RegisterJoinTable(gdb, &models.Collection{}, "Editors", &models.CollectionEditor{}); err != nil { return err }; return nil }) }`. Plan 05-01's `plugin.go` Boot already calls `classes.RegisterHooks(gdb)` — do not edit `plugin.go` or any `models/` file for this wiring. `RegisterJoinTable` is for Preload of pivot columns (`sort_order`, `role`/`granted_at`/`granted_by`); it is not a replacement for `syncArtists`, which keeps writing `golem15_fonoteka_album_artists` directly (RESEARCH.md pivot-write gap). Add `TestCollectionEditorsPivotRoundTrip` (real Postgres): insert a Collection plus a CollectionEditor row with `Role`, `GrantedAt`, `GrantedBy` set to known values, reload via `Preload("Editors")` on Collection (after Boot/`RegisterHooks` has run) and via a direct `Take` of CollectionEditor, and assert all three pivot columns survive the round-trip.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestValidate && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./... -run 'TestAlbumArtistsOrderRoundTrip|TestCollectionEditorsPivotRoundTrip|TestSaveAlbum|TestSaveAlbumMoneyNormalization'</automated>
</verify>
<acceptance_criteria>
- `lagoon.Validate` unit tests cover: `between`, `oneof` with a quoted-space value, the custom money-range func, `unique:table` respecting `deleted_at`, and an unrecognized-rule error.
- `TestAlbumArtistsOrderRoundTrip` (integration, real Postgres) saves a 3-artist album via `SaveAlbum`, re-reads with `Preload("Artists").Order(...)` on the pivot's `sort_order`, and asserts the order survived a subsequent re-save with a shuffled `artistIDsInOrder`.
- `TestCollectionEditorsPivotRoundTrip` (integration, real Postgres) inserts a CollectionEditor with `role`/`granted_at`/`granted_by` set and reloads those three columns via `Preload("Editors")` after `lagoon.RegisterJoinTable` has run.
- `grep -n "RegisterJoinTable" fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go` shows both `Artists` and `Editors` call sites; `grep -n "RegisterJoinTable" fonoteka.go/plugins/golem15/fonoteka/models` returns no matches (models-leaf).
- `TestSaveAlbumMoneyNormalization` (integration, real Postgres) round-trips through `SaveAlbum` for: blank string, explicit `nil`, non-numeric string, the PHP ceiling case (`"12.55555"` -> `"12.5556"`), and a normal value (`"25"` -> `"25.0000"`) — every case re-read from the database, not asserted only against the in-memory struct.
- `grep -rn "Association(\"Artists\")" fonoteka.go/plugins/golem15/fonoteka/classes` returns no matches.
- A Go test asserts `AlbumFillFields` excludes both `collection_id` and `market_price_source`, which are present in `Album.Fillable()`.
</acceptance_criteria>
<done>`lagoon.Validate` exists and is tested against this slice's 5 rule-bearing models; `Album.BeforeSave` ports `refreshTrackTitles`/`stampMarketPrice` with the blank/null/non-numeric/ceiling/currency/source/checked_at semantics proven via a real-Postgres round-trip; `AlbumWriteService`/`CollectionWriteService` fill boundaries and `syncArtists` exist and round-trip pivot order on real Postgres; `ArtistResolver` fires as a registered callback, not inline model code.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| caller -> AlbumWriteService/CollectionWriteService | untrusted `requested` map reaches persisted columns through the two-layer fillable boundary |
| caller -> lagoon.Validate `unique:table` | untrusted field value reaches a raw SQL `WHERE column = ?` — parameterized, but the table/column names driving the query come from a model's own `Rules()` map, not request input |
| caller -> Album.BeforeSave's stampMarketPrice | an untrusted `market_price_stored` string reaches numeric parsing; a non-numeric/blank value must resolve to NULL, never a parse panic or a silently-truncated number |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-04 | Tampering / Elevation of Privilege | `AlbumWriteService.FILL_FIELDS` | mitigate | Narrower service-level allow-list excludes `collection_id`/`market_price_source`; Plan 05-06's fuzz test asserts nothing outside the list is ever persisted (D-05/D-07) |
| T-05-05 | Tampering | `syncArtists` pivot write | mitigate | Delete-then-bulk-insert runs inside the same transaction as the parent save; a failed insert rolls back the delete too, so a partial write can never leave an album with zero artists |
| T-05-06 | Information Disclosure | `lagoon.Validate`'s `unique:table` query | accept | Table/column names are sourced from the model's own compiled `Rules()` map, never from request input; the value is parameterized |
| T-05-07 | Tampering (data integrity) | `golem15_fonoteka_collections.public_token` unique constraint | accept | Matches PHP's actual plain-unique constraint exactly (no partial index) per the D-02 audit; the delete-then-recreate behavioral test lives in Plan 05-06, this plan only creates the column |
| T-05-23 | Denial of Service | `Album.BeforeSave`'s money-string parsing | mitigate | Non-numeric/blank input is normalized to NULL via a checked `strconv.ParseFloat`, never an unguarded numeric conversion that could panic on malformed input |
| T-05-24 | Repudiation | `Collection.BeforeDelete`'s cascade | mitigate | Runs inside the same transaction as the parent delete via `lagoon.WithSoftDeleteCascade`; a cascade failure rolls back the parent delete too, so a Collection can never end up deleted with orphaned non-cascaded Albums |
</threat_model>
<verification>
`cd summercms.go && go vet ./... && go test ./lagoon/... -short` then the full (non-short) `go test ./lagoon/...`; `cd ../fonoteka.go && go vet ./... && go test ./...` (real Postgres via testcontainers) covering the new migrations, models, hooks, validation, and write services.
</verification>
<success_criteria>
- All 6 migrations run up/down individually and the Various Artists seed is idempotent; `track_titles` exists on `golem15_fonoteka_albums`.
- `Album`/`Collection` are at their final column set with `Fillable`/`Hidden`/`Rules`; `Artist`/`Style`/`AlbumArtist`/`AlbumRating`/`AlbumReservation` exist and self-register.
- Artist/Genre/Style's `BeforeValidate`/`AfterDelete` hooks and Collection's `BeforeDelete` cascade are ported verbatim from the PHP source and proven against real Postgres, not left as unwired framework primitives.
- `lagoon.Jsonable[T]`, `MoneyString`, and `lagoon.Validate` exist, are tested, and never route a money value through `float64`.
- `Album.BeforeSave` ports `refreshTrackTitles`+`stampMarketPrice` and a real-Postgres round-trip proves the blank/null/non-numeric/ceiling/currency/source cases.
- A 3+ artist album's `sort_order` round-trips through `syncArtists`, never `Association().Append`; Collection.Editors pivot columns round-trip after `lagoon.RegisterJoinTable`.
- `discogs_id` and `discogs_artist_id` are nullable TEXT/`*string` with the PHP indexes, never INTEGER/`*uint`.
- `AlbumWriteService`/`CollectionWriteService` fill boundaries exist and are narrower than their models' `Fillable()`.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-02-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,259 @@
---
phase: 05-data-layer-full-fidelity
plan: 03
type: execute
wave: 2
depends_on: ["05-01"]
files_modified:
- summercms.go/lagoon/encrypted.go
- summercms.go/lagoon/encrypted_test.go
- summercms.go/lagoon/keygen.go
- summercms.go/lagoon/keygen_test.go
- summercms.go/lagoon/commands.go
- summercms.go/lagoon/laravel_decrypt.go
- summercms.go/lagoon/laravel_decrypt_test.go
- fonoteka.go/config/app.yaml
- fonoteka.go/plugins/golem15/user/updates/10_organisations.go
- fonoteka.go/plugins/golem15/user/models/organisation.go
- fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go
- fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go
- fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/org_ai_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/org_discogs_credential.go
autonomous: true
requirements: [DATA-07, DATA-09]
must_haves:
truths:
- "lagoon.Encrypted round-trips AES-256-GCM ciphertext with a column key HKDF-derived from app.key under a fixed label using the Go 1.27 standard library's crypto/hkdf (no new dependency); a missing, short, or undecodable app.key fails boot with a named actionable error and no default (D-10, D-11)"
- "Ciphertext is versioned (format/key-id prefix + nonce + ciphertext+tag), and app.previous_keys is a decrypt-only fallback list, mirroring Laravel's APP_PREVIOUS_KEYS (D-12)"
- "lagoon.Encrypted.MarshalJSON/.String()/.GoString() always emit a redaction; plaintext is reachable only through an explicit .Reveal() call, greppable across the codebase (D-13)"
- "summer key:generate prints a fresh 32-byte base64 key and performs no other side effect (D-11)"
- "A standalone helper decrypts a Laravel AES-256-CBC+HMAC 'encrypted' payload for the future cutover import, and is never called from lagoon.Encrypted's live Scan/Value path (D-10)"
- "golem15_user_organisations (structure only) is created by the user plugin's own migration, not fonoteka's; Phase 5 ships no widen_users migration at all — a deliberate deviation from D-03's literal wording, confirmed by the user at plan time"
- "api_tokens, the 3 OAuth tables, and the 4 credential tables exist with token_hash/code_hash/client_secret_hash columns Hidden and api_key/token columns typed lagoon.Encrypted and Hidden (DATA-07, DATA-09)"
artifacts:
- path: summercms.go/lagoon/encrypted.go
provides: "Encrypted Scanner/Valuer + Reveal() + versioned AES-256-GCM format"
- path: summercms.go/lagoon/keygen.go
provides: "key:generate bonfire.Command"
- path: fonoteka.go/plugins/golem15/user/updates/10_organisations.go
provides: "create_organisations (structure only)"
- path: fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go
provides: "create_api_tokens, create_oauth_tables, create_credentials_tables"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go
to: summercms.go/lagoon/encrypted.go
via: "APIKey lagoon.Encrypted `gorm:\"column:api_key\"`"
pattern: "lagoon\\.Encrypted"
- from: summercms.go/lagoon/encrypted.go
to: fonoteka.go/config/app.yaml
via: "app.key read at column-key derivation time, fails boot if empty/short"
pattern: "app\\.key|SUMMER_APP__KEY"
---
<objective>
Ship the encrypted-at-rest cast and the tables/models that need it: the AES-256-GCM `lagoon.Encrypted` type with HKDF key derivation (Go 1.27 standard library `crypto/hkdf`, no new dependency) and versioned ciphertext (D-10..D-13), the `summer key:generate` command, a standalone (unwired) Laravel-payload decrypt helper for the future cutover import, `golem15_user_organisations` (structure only, in the user plugin per the user's confirmed deviation from D-03's wording), and the fonoteka `api_tokens`/OAuth/credential tables and models.
Purpose: this is the phase's other named security-load-bearing surface (mass assignment was Plan 02) — encryption key handling, redaction discipline, and the fact that Organisations land in the *user* plugin's migration set even though only Fonoteka's credential tables reference them this phase.
Output: `lagoon.Encrypted`, `lagoon.KeyGenerateCommand`, a Laravel-decrypt helper; `golem15_user_organisations`; `ApiToken`, `OAuthClient`, `OAuthAuthCode`, `OAuthRefreshToken`, `UserAiCredential`, `OrgAiCredential`, `UserDiscogsCredential`, `OrgDiscogsCredential` models and their 3 migrations.
</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/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md
</context>
<interfaces>
<!-- Plan 05-01 contracts this plan's models consume -->
From summercms.go/lagoon/fill.go (Plan 05-01):
```go
type HasHidden interface { Hidden() []string }
```
From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go, and the equivalent in plugins/golem15/user (Plan 05-01):
```go
func Register(models ...any) // package models
func All() []any // package models
func Register(ms ...*gormigrate.Migration) // package updates
func All() []*gormigrate.Migration // package updates
```
From summercms.go/bouncer/jwt.go Verify (the fail-loud-on-empty-secret shape to copy for app.key):
```go
func Verify(tokenString, secret string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", fmt.Errorf("bouncer: jwt secret is empty")
}
...
}
```
From summercms.go/lagoon/commands.go RuntimeCommands (the command-trio shape key:generate joins):
```go
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
return []bonfire.Command{ {Name: "migrate", ...}, {Name: "migrate:rollback", ...}, {Name: "migrate:status", ...} }
}
```
From summercms.go/compass/config.go (dot-path config access `app.key`/`app.previous_keys` will use):
```go
func (c *Config) String(path string) string
func (c *Config) Lookup(path string) (any, bool)
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1 (summercms.go): lagoon.Encrypted — AES-256-GCM, stdlib HKDF key derivation, versioned ciphertext, key:generate, Laravel decrypt-only helper</name>
<files>summercms.go/lagoon/encrypted.go, summercms.go/lagoon/encrypted_test.go, summercms.go/lagoon/keygen.go, summercms.go/lagoon/keygen_test.go, summercms.go/lagoon/commands.go, summercms.go/lagoon/laravel_decrypt.go, summercms.go/lagoon/laravel_decrypt_test.go</files>
<read_first>
summercms.go/bouncer/jwt.go (Verify's fail-loud-on-empty-secret shape, lines 67-70)
summercms.go/lagoon/connection.go (checkLocale's maximally-actionable-error-message shape, lines 129-136)
summercms.go/lagoon/commands.go (RuntimeCommands trio shape to extend)
summercms.go/compass/config.go (String/Lookup dot-path access)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Pattern: Encrypted-at-rest cast (D-10..D-13)" section; Sources list's `laravel.com/docs/11.x/encryption` citation for the CBC+HMAC payload shape)
.planning/research/PITFALLS.md (Security Mistakes section)
</read_first>
<behavior>
- `Encrypted.Scan` then `.Reveal()` round-trips arbitrary plaintext bytes through `Value()`/`Scan()` using a key derived from a fixed test `app.key`.
- Two `Encrypted` values holding the same plaintext produce different ciphertext bytes (fresh nonce per encrypt call).
- `json.Marshal(encryptedValue)`, `encryptedValue.String()`, and `fmt.Sprintf("%#v", encryptedValue)` never contain the plaintext substring.
- A ciphertext produced under key-id 1, then decrypted after `app.previous_keys` gains key-id 1 as a decrypt-only fallback and the primary key rotates to key-id 2, still decrypts correctly.
- Deriving the column key from an empty, too-short (fewer than 32 decoded bytes), or non-base64 `app.key` returns a named, actionable error mentioning `SUMMER_APP__KEY` — never a zero-value/default key.
- `key:generate`'s command output is a valid base64 string that decodes to exactly 32 bytes.
- `DecryptLaravelPayload` correctly decrypts a real Laravel `encrypted` cast payload (base64 JSON `{iv,value,mac}`, AES-256-CBC + HMAC-SHA256 under the raw `APP_KEY` bytes) fixture, and `grep -rn "DecryptLaravelPayload" summercms.go/lagoon/*.go` shows it is called only from its own file and test — never from `encrypted.go`'s `Scan`/`Value`.
</behavior>
<action>
In `lagoon/encrypted.go`: implement `type Encrypted struct { plaintext []byte; set bool }` (unexported fields so nothing outside `.Reveal()`/`.Value()`/internal marshal code can read `plaintext` directly) with `func (e *Encrypted) Scan(src any) error` (decode the versioned format below and store plaintext internally, `set=true`), `func (e Encrypted) Value() (driver.Value, error)` (re-encrypt current plaintext under the current primary key, return the versioned ciphertext string), `func (e Encrypted) Reveal() string` (the one explicit, greppable plaintext accessor — return `""` if `!e.set`), `func (e Encrypted) MarshalJSON() ([]byte, error)` returning `json.Marshal("[redacted]")` unconditionally, `func (e Encrypted) String() string` and `func (e Encrypted) GoString() string` both returning a fixed redaction literal (never plaintext, never even a length hint). Column key derivation: `func deriveColumnKey(appKey []byte) ([]byte, error)` using the Go 1.27 **standard library** `crypto/hkdf` package (`hkdf.Key(sha256.New, appKey, salt, info, 32)`, or the `Extract`/`Expand` pair — Go 1.24+ ships HKDF in stdlib, so this introduces **no new dependency**; do not add `golang.org/x/crypto` or run `go get` for this) with SHA-256, a fixed info label string like `"summercms.lagoon.encrypted.v1"`, producing a 32-byte AES-256 key; `appKey` itself is loaded once at first use from `app.key` (base64-decoded, must decode to exactly 32 raw bytes) via a package-level function `LoadAppKey(cfg *compass.Config) ([]byte, []byte, error)` returning `(primaryKey, previousKeysConcat, error)` — reads `app.key` and `app.previous_keys` (a YAML list of base64 strings) from `cfg`, fails with `fmt.Errorf("lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)")`-shaped errors on empty/short/undecodable, following `bouncer.Verify`'s and `lagoon.checkLocale`'s fail-loud verbosity exactly. Ciphertext format: a single byte format/key-id version prefix, a 12-byte GCM nonce, then GCM-sealed ciphertext+tag, the whole thing base64-encoded for storage in the `text` column (matches RESEARCH.md's confirmed `text` column type for `api_key`/`token`). `Scan` tries the primary derived key first, then each of `previousKeys` in order, returning a decrypt error only if none match (D-12's decrypt-only fallback). Publish the loaded keys once per app boot via `backpack.App.Publish`, looked up by `Scan`/`Value` — do not re-read config on every row; document the exact wiring call site (`lagoon.PublishEncryptionKeys(app, primaryKey, previousKeys)`, called from wherever `lagoon.OpenFromApp`/`Publish` already runs) in a doc comment.
In `lagoon/keygen.go`: implement `func KeyGenerateCommand() bonfire.Command` returning `bonfire.Command{Name: "key:generate", Description: "Print a fresh 32-byte base64 app.key", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... }}` using `crypto/rand.Read` on a 32-byte buffer, `out.Success(base64.StdEncoding.EncodeToString(key))` — never `math/rand`. Append `KeyGenerateCommand()` to the slice `RuntimeCommands` returns in `lagoon/commands.go` (one new line in the existing slice literal — a framework-owned file no other plan in this phase touches).
In `lagoon/laravel_decrypt.go`: implement `func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error)` — base64-JSON-decode `{iv, value, mac}` per Laravel's `encrypted` cast format, verify the HMAC-SHA256 `mac` over `iv+value` using `appKey` (raw bytes — Laravel uses the raw `APP_KEY` directly for both cipher and MAC key, verify against the RESEARCH.md-cited Laravel docs), then AES-256-CBC-decrypt `value` with `iv`, unpadding PKCS#7. Doc-comment it "cutover-import-only; never called from `Encrypted`'s live Scan/Value path" (D-10).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted</automated>
</verify>
<acceptance_criteria>
- All seven `Encrypted`/`key:generate`/`DecryptLaravelPayload` behaviors above pass as automated tests.
- `grep -rn "DecryptLaravelPayload("` shows zero call sites outside its own file and test.
- `grep -rn "golang.org/x/crypto" summercms.go/lagoon/encrypted.go summercms.go/go.mod` returns no matches (stdlib `crypto/hkdf` only, no new dependency).
- `go vet ./lagoon/...` is clean; `grep -rn "\"math/rand\"" summercms.go/lagoon/keygen.go` returns no matches.
</acceptance_criteria>
<done>`lagoon.Encrypted`, key derivation/rotation, `key:generate`, and the standalone Laravel decrypt helper exist, are fully tested, redaction is provably unbypassable via marshal/String/GoString, and HKDF derivation uses only the Go 1.27 standard library.</done>
</task>
<task type="auto">
<name>Task 2 (fonoteka.go): create_organisations (structure only) in the user plugin — no widen_users migration this phase</name>
<files>fonoteka.go/plugins/golem15/user/updates/10_organisations.go, fonoteka.go/plugins/golem15/user/models/organisation.go, fonoteka.go/config/app.yaml</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.2.0/create_organisations_table.php (confirm the exact filename via a directory listing if the version directory differs)
fonoteka.go/plugins/golem15/user/updates/00_base.go (moved by Plan 05-01 — the exact ID-naming and Migrate/Rollback shape to follow)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (user_confirmed_decisions #2)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("widen_users finding", "Squashed Migration List" row 2, the anti-pattern note on organisations table ownership)
</read_first>
<action>
Create `updates/10_organisations.go` (package `updates`, in `plugins/golem15/user`) with a doc comment stating it folds `updates/v3.2.0/create_organisations_table.php` and explicitly noting: "Phase 5 ships no widen_users migration — deferred to Phase 7's AUTH-02 per 05-CONTEXT.md user-confirmed decision; this migration only creates the FK target `golem15_user_organisations` table that Fonoteka's org credential tables reference." Read the PHP migration file directly for its exact column list; at minimum expect `id SERIAL PRIMARY KEY`, `name TEXT NOT NULL`, `created_at`/`updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. Do not add `organisation_id`/`organisation_role` columns to `users`, and do not add membership/role tables even if the PHP version directory bundles more into the same migration — split them out, porting only the bare table this phase. `var organisationsMigrations = []*gormigrate.Migration{{ID: "<next-sequential>_create_organisations", Migrate: ..., Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS golem15_user_organisations").Error }}}`, with `func init() { updates.Register(organisationsMigrations...) }`.
Create `models/organisation.go` (package `models`, in `plugins/golem15/user`) with `type Organisation struct { ID uint; Name string; CreatedAt time.Time; UpdatedAt time.Time }`, `func (Organisation) TableName() string { return "golem15_user_organisations" }`, `func init() { models.Register(Organisation{}) }` — structure only, no `Fillable`/`Rules`/relations beyond the FK target Fonoteka's credential models need (Phase 7 owns roles/membership behavior).
Add an `app.key: ""` entry to `fonoteka.go/config/app.yaml` as a documented placeholder — following `config/database.yaml`'s "Set SUMMER_..." comment-header convention: "# Set SUMMER_APP__KEY to a 32-byte base64 value (summer key:generate). Empty fails boot once any encrypted-cast model runs." Do not set a real value.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./...</automated>
</verify>
<acceptance_criteria>
- `golem15_user_organisations` is created by a testcontainers-backed migration test in the `user` plugin's own module, not fonoteka's.
- `grep -rn "organisation_id\|organisation_role" fonoteka.go/plugins/golem15/user/updates/10_organisations.go` returns no matches on the `users` table (only the new table's own columns exist).
- `grep -rln "widen_users" fonoteka.go/plugins/golem15/user/updates` returns no matches (no such migration exists).
</acceptance_criteria>
<done>`golem15_user_organisations` exists as a structure-only table owned by the user plugin's migration set; no widen_users migration ships this phase.</done>
</task>
<task type="auto">
<name>Task 3 (fonoteka.go): secrets-slice migrations and models — api_tokens, OAuth tables, credential tables, all with Hidden hash/secret columns and lagoon.Encrypted credential columns</name>
<files>
fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go,
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, models/oauth_client.go, models/oauth_auth_code.go, models/oauth_refresh_token.go,
fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, models/org_ai_credential.go, models/user_discogs_credential.go, models/org_discogs_credential.go
</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.3/create_fonoteka_api_tokens_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.6/add_area_ids_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/add_oauth_client_id_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.4/create_user_ai_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.5/create_user_discogs_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.3/create_org_credentials_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserAiCredential.php, OrgAiCredential.php, UserDiscogsCredential.php, OrgDiscogsCredential.php (exact $casts/$hidden confirmation)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 4, 13, 14, 15, 16, 17, 21, 23; "Pattern: Encrypted-at-rest cast" code example)
</read_first>
<action>
Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs following `updates/10_album_slice.go`'s date/sequence convention (independent numbering from Plan 05-02's file — both are new files in the same wave, no shared line edited):
1. `create_api_tokens` — `golem15_fonoteka_api_tokens (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, token_hash TEXT NOT NULL UNIQUE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, last_used_ip TEXT, oauth_client_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify exact types against the two folded files; `scopes`/`collection_ids` are `text` jsonable columns per RESEARCH.md row 4, not native `jsonb`).
2. `create_oauth_tables` — `golem15_fonoteka_oauth_clients (id SERIAL PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash TEXT NOT NULL, client_name TEXT, redirect_uris TEXT, grant_types TEXT, token_endpoint_auth_method TEXT, registration_ip TEXT, consented_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, scope_ceiling TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_auth_codes (id SERIAL PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, code_hash TEXT NOT NULL UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, redirect_uri TEXT, scopes TEXT, collection_ids TEXT, code_challenge TEXT, code_challenge_method TEXT, resource TEXT, state TEXT, expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_refresh_tokens (id SERIAL PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, api_token_id INTEGER REFERENCES golem15_fonoteka_api_tokens(id) ON DELETE CASCADE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, rotated_to_id INTEGER, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify against both folded files including `scope_ceiling`).
3. `create_credentials_tables` — `golem15_fonoteka_user_ai_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, base_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_ai_credentials` (same shape, `organisation_id INTEGER NOT NULL UNIQUE REFERENCES golem15_user_organisations(id) ON DELETE CASCADE` in place of `user_id`), `golem15_fonoteka_user_discogs_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_discogs_credentials` (same shape with `organisation_id`) — `api_key`/`token` are `text` columns (never `varchar`, confirmed live per RESEARCH.md). This migration depends on `create_organisations` (Task 2) for the FK target; since gormigrate runs each plugin's set independently in `party.Activate` dependency order and `golem15.fonoteka` `Requires()` `golem15.user`, this ordering is already guaranteed — do not add an explicit cross-plugin migration dependency mechanism.
End the file with `func init() { updates.Register(apiTokenMigration, oauthTablesMigration, credentialsTablesMigration) }` (or a single slice var, matching `00_base.go`'s style).
Create the 8 model files (package `models`), each following the exact struct/tag shape from RESEARCH.md's per-model inventory rows and the D-10..D-13 code example: `ApiToken` (row 4: `UserID`, `Name *string`, `TokenHash string` `json:"-"`, `Scopes lagoon.Jsonable[[]string]`, `CollectionIDs lagoon.Jsonable[[]uint]`, `ExpiresAt`/`RevokedAt`/`LastUsedAt *time.Time`, `LastUsedIP *string`, `OAuthClientID *string`; `Hidden()` returns `{"token_hash"}`); `OAuthClient` (row 14, `ClientSecretHash string` `json:"-"`, `Hidden()` `{"client_secret_hash"}`); `OAuthAuthCode` (row 13, `CodeHash string` `json:"-"`, `Hidden()` `{"code_hash"}`); `OAuthRefreshToken` (row 15, `TokenHash string` `json:"-"`, `Hidden()` `{"token_hash"}`); `UserAiCredential`/`OrgAiCredential` (rows 21/16, `APIKey lagoon.Encrypted` `gorm:"column:api_key"` `json:"-"`, `Hidden()` `{"api_key"}`, exactly matching the code example in RESEARCH.md's "Pattern: Encrypted-at-rest cast" section); `UserDiscogsCredential`/`OrgDiscogsCredential` (rows 23/17, `Token lagoon.Encrypted` `gorm:"column:token"` `json:"-"`, `Hidden()` `{"token"}`). Every file's `init()` calls `models.Register(TheType{})`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestMigrateSeedsCanonicalGenres</automated>
</verify>
<acceptance_criteria>
- A testcontainers-backed migration test migrates `updates.All()` for `golem15.fonoteka` and asserts all 6 new tables exist (`golem15_fonoteka_api_tokens`, the 3 OAuth tables, the 4 credential tables — 8 total across the 3 migrations) and roll each back individually.
- `grep -rn "json:\"-\"" fonoteka.go/plugins/golem15/fonoteka/models/{api_token,oauth_client,oauth_auth_code,oauth_refresh_token,user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows every hash/secret/credential column tagged.
- `grep -rn "lagoon.Encrypted" fonoteka.go/plugins/golem15/fonoteka/models/{user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows exactly one match per file, on the `api_key`/`token` field.
</acceptance_criteria>
<done>3 migrations create the api_tokens/OAuth/credentials tables; 8 models exist with correct Hidden()/json:"-" and lagoon.Encrypted-typed secret columns, self-registered.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| app.key config -> Encrypted column key | a misconfigured or absent key must never silently fall back to a weak/default key |
| stored ciphertext -> old key holder | key rotation must not lock out legitimately-encrypted-under-the-old-key rows, nor allow decrypting with a key that was never a real primary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-08 | Information Disclosure | `lagoon.Encrypted` accidental serialization | mitigate | `json:"-"` backstop plus `MarshalJSON`/`String`/`GoString` all hardcoded to redact; `Hidden()` on every credential model names the same column (D-13) |
| T-05-09 | Information Disclosure / Denial of Service | key rotation | mitigate | Versioned ciphertext (key-id prefix) plus `app.previous_keys` decrypt-only fallback prevents both silent data loss and indefinite reuse of a retired key (D-12) |
| T-05-10 | Tampering | `app.key` missing/weak | mitigate | `LoadAppKey` fails boot loudly on empty/short/undecodable input, no default value path exists (D-11) |
| T-05-11 | Tampering (supply chain) | HKDF key derivation for `lagoon.Encrypted` | accept | Uses the Go 1.27 **standard library** `crypto/hkdf` package — no new dependency is introduced (this corrects an earlier draft of this plan that proposed `golang.org/x/crypto/hkdf`; Go 1.24+ ships HKDF in stdlib, so CLAUDE.md's stdlib-first rule applies directly and there is nothing for a package-legitimacy checkpoint to review) |
| T-05-12 | Repudiation | `api_tokens`/`oauth_refresh_tokens` store only `*_hash` columns, never raw secrets | accept | Matches PHP's own design; raw tokens are never persisted anywhere, hashed only, consistent with the `Hidden()` backstop |
</threat_model>
<verification>
`cd summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted` then full `go test ./lagoon/...`; `cd ../fonoteka.go && go vet ./... && go test ./...` (testcontainers Postgres) covering the new user-plugin and fonoteka-plugin migrations and models.
</verification>
<success_criteria>
- `lagoon.Encrypted` round-trips, redacts on every marshal path, supports key rotation via `app.previous_keys`, fails boot loudly on a bad `app.key`, and derives its column key using only the Go 1.27 standard library (no new dependency).
- `key:generate` and the standalone Laravel decrypt helper exist and are tested.
- `golem15_user_organisations` exists, owned by the user plugin; no `widen_users` migration ships this phase.
- All 3 secrets-slice migrations and 8 models exist, self-register, and have correct `Hidden()`/`json:"-"`/`lagoon.Encrypted` typing.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-03-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,229 @@
---
phase: 05-data-layer-full-fidelity
plan: 04
type: execute
wave: 3
depends_on: ["05-02"]
files_modified:
- summercms.go/go.mod
- summercms.go/go.sum
- summercms.go/lagoon/attach/file.go
- summercms.go/lagoon/attach/file_test.go
- summercms.go/lagoon/attach/migrations.go
- summercms.go/lagoon/attach/migrations_test.go
- summercms.go/lagoon/attach/bucket.go
- summercms.go/lagoon/attach/thumb.go
- summercms.go/lagoon/attach/thumb_test.go
- summercms.go/lagoon/attach/static.go
- summercms.go/lagoon/attach/static_test.go
- summercms.go/lagoon/migrations.go
- summercms.go/lagoon/migrations_test.go
- fonoteka.go/config/storage.yaml
- fonoteka.go/plugins/golem15/fonoteka/models/album.go
- fonoteka.go/plugins/golem15/fonoteka/models/collection.go
autonomous: true
requirements: [DATA-08, DATA-09]
must_haves:
truths:
- "The framework-owned system_files table (Winter's exact shape and column names) migrates before every plugin's own migration set runs, via lagoon.Migrate itself, not an app-level wiring call (D-14)"
- "Thumb(w,h,mode) reproduces Winter's exact filename (thumb_<id>_<w>_<h>_0_0_<mode>.<ext>) and partition-directory rule, generating lazily through gocloud.dev/blob on first call via disintegration/imaging (D-15, D-16, D-17, D-18)"
- "Files are stored/deleted through gocloud.dev/blob (fileblob in dev/prod rooted at storage/app/uploads, memblob in unit tests), never direct os.WriteFile/ReadFile (D-15, D-16)"
- "Soft-deleting an attachment's owner keeps system_files rows and blobs; force-deleting removes the row inside the delete transaction and the blob (plus thumbs) only after commit (D-19)"
- "Album and Collection carry per-model morph names that keep the PHP class string in attachment_type, and Collection's photos are ordered while its image is a single attachOne (Collection.php ~91-107)"
artifacts:
- path: summercms.go/lagoon/attach/file.go
provides: "File model (system_files) + Owner interface (MorphName)"
- path: summercms.go/lagoon/attach/migrations.go
provides: "create_system_files migration, run before every plugin set"
- path: summercms.go/lagoon/attach/thumb.go
provides: "Thumb(w,h,mode) naming + lazy disintegration/imaging resize"
- path: summercms.go/lagoon/attach/static.go
provides: "StaticHandler(bucket, prefix) http.Handler for public files (not yet route-wired — Phase 6 territory)"
key_links:
- from: summercms.go/lagoon/migrations.go
to: summercms.go/lagoon/attach/migrations.go
via: "Migrate() runs attach's migration set first, before iterating plugins"
pattern: "attach\\.Migrate\\(|attach\\.Migrations"
- from: fonoteka.go/plugins/golem15/fonoteka/models/album.go
to: summercms.go/lagoon/attach/file.go
via: "Album implements attach.Owner via MorphName() returning the PHP class string"
pattern: "func \\(Album\\) MorphName\\(\\) string"
---
<objective>
Ship the framework-owned `system_files` attachment table and `File` model, the `gocloud.dev/blob` bucket wiring (fileblob/memblob), Winter-exact `Thumb()` naming with lazy `disintegration/imaging` resize, the delete-lifecycle rule (soft-delete keeps blobs, force-delete removes them after commit), and wire `Album`'s ordered photos and `Collection`'s photos/image onto it — the full D-15 storage scope the user chose over the smaller table-only cut.
Purpose: this is the phase's largest single new subsystem (first blob-storage code in the repo) and the plan most likely to blow context if under-scoped — it ships storage end to end (metadata + bytes + thumbnails + lifecycle) but stops at the HTTP upload endpoint, which stays out of scope until Phase 12 per the phase boundary.
Output: `lagoon/attach` package (File, migrations, bucket, thumb, static handler); `disintegration/imaging` and `gocloud.dev/blob` added to `summercms.go/go.mod`; `Album`/`Collection` attachment wiring in `fonoteka.go`.
</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/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-02-SUMMARY.md
</context>
<interfaces>
<!-- Contracts from Plans 05-01/05-02 this plan's code consumes -->
From summercms.go/lagoon/lifecycle.go (Plan 05-01):
```go
type HasAfterDelete interface { AfterDelete(tx *gorm.DB) error }
func WithSoftDeleteCascade(tx *gorm.DB, cascade func(tx *gorm.DB) error) error
```
From summercms.go/lagoon/migrations.go (existing, this plan edits its `Migrate` func only):
```go
func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) (*gormigrate.Gormigrate, error)
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error
```
From fonoteka.go/plugins/golem15/fonoteka/models/album.go, models/collection.go (Plan 05-02, widened):
```go
type Album struct { ID uint; ... } // gains a MorphName() method and photo relation this plan
type Collection struct { ID uint; ... } // gains MorphName(), photos (ordered), image (single) this plan
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1 (summercms.go): lagoon/attach core — File model, system_files migration wired before every plugin set, blob bucket, Thumb() naming with lazy imaging resize</name>
<files>
summercms.go/go.mod, summercms.go/go.sum,
summercms.go/lagoon/attach/file.go, attach/file_test.go, attach/migrations.go, attach/migrations_test.go, attach/bucket.go, attach/thumb.go, attach/thumb_test.go,
summercms.go/lagoon/migrations.go, migrations_test.go,
fonoteka.go/config/storage.yaml
</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Attach/File.php (lines ~440-1050: getThumbFilename, getPartitionDirectory, getStorageDirectory, delete behavior)
/media/nvme/dev/golem15/fonoteka/modules/system/database/migrations/2013_10_01_000002_Db_System_Files.php
/media/nvme/dev/golem15/fonoteka/config/cms.php (lines ~300-335, storage.uploads config shape)
summercms.go/lagoon/migrations.go (migrator() helper, Migrate() loop to modify)
summercms.go/lagoon/connection.go (Publish/one-shared-handle discipline)
summercms.go/backpack/services.go (Registry.Publish/Lookup)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("system_files verified column set", "Winter File thumb filename + partition rule" code example, "disintegration/imaging thumbnail generation" code example, D-14..D-18 decisions)
</read_first>
<behavior>
- `Thumb(42, 200, 200, "0", "0", "crop", "jpg")`-shaped naming call (or the equivalent method signature on `File`) returns exactly `"thumb_42_200_200_0_0_crop.jpg"`.
- Partition directory for `disk_name="abc123xyz.jpg"` is exactly `"abc/123/xyz/"`.
- Calling `File.Thumb(gdb, bucket, 200, 200, "crop")` twice on the same file only invokes the resize path once (second call finds the existing thumb blob key and returns its URL without re-resizing) — verified against a `memblob` bucket with a resize-call counter.
- `lagoon.Migrate` run against a fresh database creates `system_files` before any plugin's own tables (verified by asserting the history/table exists even when the plugin list is empty).
</behavior>
<action>
Add dependencies: `cd summercms.go && go get gocloud.dev/blob@v0.46.0 && go get github.com/disintegration/imaging@v1.6.2` (both are `[OK]`/Approved in RESEARCH.md's Package Legitimacy Audit — no blocking checkpoint needed).
Create `lagoon/attach/file.go` (package `attach`) with `type File struct { ID uint; DiskName string; FileName string; FileSize int64; ContentType string; Title *string; Description *string; Field string; AttachmentID string; AttachmentType string; IsPublic bool; SortOrder int; Metadata *string; CreatedAt time.Time; UpdatedAt time.Time }` and `func (File) TableName() string { return "system_files" }` — column names exactly as RESEARCH.md's verified `system_files` set, noting `AttachmentID string` (Winter's morph FK is a string, never an integer, per the verified live-schema note). Declare `type Owner interface { MorphName() string }` — any Fonoteka model implements this to keep the PHP class string (e.g. `"Golem15\\Fonoteka\\Models\\Album"`) in `AttachmentType` on save. `File` self-registers via `attach.Register` — reuse the exact `models.Register`/`All` registry shape from Plan 05-01 but scoped to this package (`var all []any; func Register(...); func All() []any`) so `lagoon.Migrate`/schema tooling can see it without depending on any single plugin's registry.
Create `lagoon/attach/migrations.go` with `var Migrations = []*gormigrate.Migration{{ID: "<date>0001_create_system_files", Migrate: func(tx *gorm.DB) error { /* raw CREATE TABLE system_files per RESEARCH.md's verified column set, plus an index on (attachment_type, attachment_id, field) */ return nil }, Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS system_files").Error }}}`.
Edit `lagoon/migrations.go`'s existing `Migrate(gdb *gorm.DB, plugins []party.Plugin) error`: as its very first statement (before the `for _, p := range plugins` loop), call `m, err := migrator(gdb, "summercms.attach", attach.Migrations); if err != nil { return err }; if err := m.Migrate(); err != nil { return fmt.Errorf("lagoon: migrate system_files: %w", err) }` (reusing the existing unexported `migrator()` helper with a synthetic plugin id — no plugin needs to know this ran). This is the "runs before every plugin set" wiring (D-14); it is the only line this task adds to a file another plan might also touch — no other Phase 5 plan edits `lagoon/migrations.go`, so this is collision-free.
Create `lagoon/attach/bucket.go` with `func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error)` reading `storage.uploads.bucket_url` (a `fileblob://` or `mem://` URL per `gocloud.dev/blob`'s `blob.OpenBucket` convention) and `storage.uploads.public_path_prefix` from config, failing loudly if `bucket_url` is empty (same fail-boot discipline as `app.key`/`jwt.secret`); publish the opened bucket once via `app.Publish(bucket)` (mirror `lagoon.Publish`'s one-shared-handle discipline) — add `fonoteka.go/config/storage.yaml` with `uploads:\n bucket_url: "file://./storage/app/uploads"\n public_path_prefix: "/storage/uploads"` mirroring `cms.php`'s `storage.uploads` shape (D-16).
Create `lagoon/attach/thumb.go` implementing the two pure-string functions verbatim from RESEARCH.md's verified Winter source: `func ThumbFilename(id uint, w, h int, offsetX, offsetY int, mode, ext string) string` (`"thumb_%d_%d_%d_%d_%d_%s.%s"`) and `func PartitionDirectory(diskName string) string` (first 9 chars of `diskName` split into 3 groups of 3, joined by `/`, trailing `/`). Implement `func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error)` that computes the thumb key via the two functions above, checks `bucket.Exists(ctx, thumbKey)` first (lazy generation — do not resize if the thumb blob already exists, satisfying the "reused at cutover" requirement and the "only resize once" behavior test), and only on a cache miss reads the original via `bucket.NewReader`, decodes with `image.Decode`, resizes with `imaging.Fill`/`imaging.Resize`/`imaging.Fit` per the mode-to-function mapping in RESEARCH.md's code example (`crop`->`Fill`, `exact`->`Resize`, default `auto`->`Fit`, all with `imaging.Lanczos`), writes the result via `bucket.NewWriter`, and returns the computed public URL (`public_path_prefix` + partition + filename).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run 'TestThumbFilename|TestPartitionDirectory|TestFileLifecycle|TestMigrateRunsSystemFilesFirst' -short</automated>
</verify>
<acceptance_criteria>
- `ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg")` returns exactly `"thumb_42_200_200_0_0_crop.jpg"`.
- `PartitionDirectory("abc123xyz.jpg")` returns exactly `"abc/123/xyz/"`.
- `File.Thumb` called twice against a `memblob` bucket resizes exactly once (assert via a counting `imaging` wrapper or by asserting the second call's `bucket.NewReader` on the original is never opened).
- A test asserts `system_files` exists immediately after `lagoon.Migrate(gdb, nil)` (empty plugin list) — proving the framework migration runs independent of any plugin.
- `go.mod` pins `gocloud.dev v0.46.0` and `github.com/disintegration/imaging v1.6.2`.
</acceptance_criteria>
<done>`lagoon/attach` exists with `File`, the `system_files` migration wired ahead of every plugin set, blob bucket wiring, and `Thumb()` matching Winter's exact naming with lazy, cached resize via `disintegration/imaging`.</done>
</task>
<task type="auto">
<name>Task 2 (summercms.go): delete lifecycle (soft-delete keeps blobs, force-delete removes after commit) and a static file handler for public attachments</name>
<files>summercms.go/lagoon/attach/file.go, summercms.go/lagoon/attach/file_test.go, summercms.go/lagoon/attach/static.go, summercms.go/lagoon/attach/static_test.go</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Attach/File.php (delete method — confirm the exact soft-delete-keeps-row-and-blob vs. force-delete-removes behavior)
summercms.go/lagoon/lifecycle.go (Plan 05-01, HasAfterDelete, WithSoftDeleteCascade)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (D-16, D-19)
</read_first>
<behavior>
- Soft-deleting an owner (e.g. `tx.Delete(&Album{...})` where `Album` has `gorm.DeletedAt`) leaves its `system_files` rows and blob bytes untouched.
- Force-deleting an owner (`tx.Unscoped().Delete(...)`) removes the `system_files` row inside the same transaction as the owner's delete, and the original blob plus any generated thumbs are removed from the bucket only after the transaction commits (never inside it — a rollback must not have already deleted bytes it can't get back).
- `attach.StaticHandler(bucket, prefix)` serves a stored blob's bytes with the correct `Content-Type` at `prefix/<partition>/<disk_name>` via `httptest.NewServer`, and 404s for a missing key; unvalidated path segments never reach `bucket.NewReader`.
</behavior>
<action>
Add `func DeleteForOwner(tx *gorm.DB, owner Owner, ownerID string, afterCommit func(blobKeys []string) error) error` to `file.go`: inside `tx`, `SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ?` for the rows about to be removed, `DELETE` those rows in the same statement/transaction, then register `afterCommit` to run once `tx.Commit()` succeeds — GORM does not have a native "after commit" hook, so implement this by having the caller (a model's `AfterDelete(tx *gorm.DB) error` hook, wired the same way Plan 05-02's `ArtistResolver` callback was wired via `classes.RegisterHook`) collect blob keys during the transaction and issue the actual `bucket.Delete` calls immediately after the top-level `.Unscoped().Delete(...)` call returns without error in the caller's own code — document this two-phase contract precisely in a doc comment on `DeleteForOwner`, since it is a real GORM limitation (no post-commit hook), not an oversight. Only wire this contract into the framework helper here; the first real caller (Album/Collection force-delete) is exercised by this plan's Task 3 smoke test, not a production route (none exists yet).
In `static.go` implement `func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler` (D-16): serve GET `prefix/<partition>/<disk_name>` by reconstructing the blob key from `PartitionDirectory(disk_name)+disk_name` (the Winter 3x3 partition rule Task 1 already shipped) and reading that exact key via `bucket.NewReader`. Set `Content-Type` from the blob's attributes if present, otherwise from a documented default such as `application/octet-stream`. Exact-match keys only: reject `..`, extra slashes, empty segments, and any path that is not exactly the 3-group partition plus a single `disk_name` file component; never pass unvalidated request path segments to `bucket.NewReader` (T-05-13). A missing key returns 404. Do NOT register this handler on any plugin route (D-16 / this phase's HTTP-route boundary) — `static_test.go`'s `TestStaticHandler` drives it only through `httptest.NewServer`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/attach/... -run 'TestFileLifecycle|TestStaticHandler'</automated>
</verify>
<acceptance_criteria>
- `TestFileLifecycle` proves soft-delete keeps the row+blob and force-delete removes the row in-tx with blob removal deferred to after commit (assert the blob still exists immediately inside a test transaction that has not yet committed, then confirm it is gone after commit).
- `attach.StaticHandler(bucket, prefix)` serves a stored blob's bytes with the correct `Content-Type` at `prefix/<partition>/<disk_name>` via `httptest.NewServer`, and 404s for a missing key — no route registration in any plugin, per this phase's HTTP-route boundary.
</acceptance_criteria>
<done>Delete lifecycle matches Winter's soft-delete/force-delete split with post-commit blob removal; a static handler exists and is tested directly, not yet wired into any route table.</done>
</task>
<task type="auto">
<name>Task 3 (fonoteka.go): Album/Collection attachment wiring — MorphName, ordered photos, Collection's single image</name>
<files>fonoteka.go/plugins/golem15/fonoteka/models/album.go, fonoteka.go/plugins/golem15/fonoteka/models/collection.go</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (attachMany photos declaration)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (lines ~91-107: attachMany photos ordered, attachOne image)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (lines ~190-215: photo payload shape, getThumb(200,200,['mode'=>'crop']), relativeMediaUrl — port only what this plan's own smoke test needs)
summercms.go/lagoon/attach/file.go (Owner interface, this plan's Task 1)
</read_first>
<action>
Add `func (Album) MorphName() string { return "Golem15\\Fonoteka\\Models\\Album" }` and `func (Collection) MorphName() string { return "Golem15\\Fonoteka\\Models\\Collection" }` to the respective model files — the literal PHP class strings (verify exact casing/namespace against the PHP files' own `namespace`/class declarations), satisfying `attach.Owner` so cutover-copied `system_files` rows keep matching against these Go models unchanged. Document in a comment that `Album`'s photos are `attachMany` (ordered by `sort_order`, queried via `system_files WHERE attachment_type = Album.MorphName() AND attachment_id = ? AND field = 'photos' ORDER BY sort_order`) and `Collection` has both an ordered `photos` `attachMany` and a single `image` `attachOne` (`field = 'image'`, at most one row) — these are query helpers on the `classes/` write-service layer (not a new file this task adds; note the follow-up call site for Plan 05-06's smoke test), not new relation-tag machinery, since `system_files` is framework-owned and polymorphic rather than a GORM-native relation.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./...</automated>
</verify>
<acceptance_criteria>
- `grep -n "MorphName" fonoteka.go/plugins/golem15/fonoteka/models/album.go fonoteka.go/plugins/golem15/fonoteka/models/collection.go` shows both methods present with the exact PHP class-string literals.
- `go vet` and `go build` are clean; no new relation tag or migration is introduced (attachments stay entirely framework-owned via `system_files`, per D-14).
</acceptance_criteria>
<done>Album and Collection satisfy `attach.Owner` with the exact PHP class strings; Collection's ordered photos and single image are documented query shapes ready for Plan 05-06's smoke test.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| stored blob path -> static handler | `disk_name`/partition path must never allow traversal outside the configured bucket root |
| force-delete -> blob removal timing | a rolled-back transaction must never have already deleted bytes it cannot restore |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-13 | Tampering (path traversal) | `attach.StaticHandler` key construction | mitigate | Keys are built only from `disk_name` values already persisted by `system_files` rows (server-generated, never taken from the request path beyond an exact-match lookup); the handler does not accept caller-supplied directory segments that reach `bucket.NewReader` unvalidated |
| T-05-14 | Tampering (data loss) | Force-delete blob removal ordering | mitigate | `DeleteForOwner`'s documented two-phase contract removes DB rows inside the transaction and blobs only after commit succeeds, so a rollback never leaves orphaned-but-unrecoverable bytes deleted early |
| T-05-15 | Tampering (supply chain) | `gocloud.dev/blob`, `disintegration/imaging` | accept | Both `[OK]` in RESEARCH.md's Package Legitimacy Audit; no blocking human-verify checkpoint required |
| T-05-16 | Information Disclosure | `attach.StaticHandler` serving `is_public=false` rows | accept | This phase's `StaticHandler` is not yet wired into any route (HTTP-route boundary of this phase); the `is_public` gate is Phase 6/12's job when the handler is actually mounted — flagged here so it is not forgotten at wiring time |
</threat_model>
<verification>
`cd summercms.go && go vet ./lagoon/... && go test ./lagoon/attach/... -short` then the full non-short suite (testcontainers Postgres + memblob); `cd ../fonoteka.go && go build ./... && go vet ./...`.
</verification>
<success_criteria>
- `system_files` migrates before every plugin's own set, verified with an empty plugin list.
- `Thumb()` matches Winter's exact filename/partition rules and resizes lazily, once, via `disintegration/imaging`.
- Soft-delete/force-delete lifecycle matches D-19 exactly, including deferred post-commit blob removal.
- `Album`/`Collection` implement `attach.Owner` with the correct PHP class strings.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-04-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,225 @@
---
phase: 05-data-layer-full-fidelity
plan: 05
type: execute
wave: 4
depends_on: ["05-02", "05-03", "05-04"]
files_modified:
- fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go
- fonoteka.go/plugins/golem15/fonoteka/models/collection_invitation.go
- fonoteka.go/plugins/golem15/fonoteka/models/pending_invitation_registration.go
- fonoteka.go/plugins/golem15/fonoteka/models/csv_import.go
- fonoteka.go/plugins/golem15/fonoteka/models/csv_import_row.go
- fonoteka.go/plugins/golem15/fonoteka/models/notification.go
- fonoteka.go/plugins/golem15/fonoteka/models/wishlist_subscription.go
- fonoteka.go/plugins/golem15/fonoteka/models/wishlist_digest_queue.go
- fonoteka.go/plugins/golem15/fonoteka/models/settings.go
- summercms.go/lagoon/validate.go
- fonoteka.go/parity/fixtureplugin/plugin.go
- fonoteka.go/parity/fixtureplugin/migrations.go
- fonoteka.go/parity/cross_plugin_callback_test.go
- fonoteka.go/parity/testdata/php_schema_snapshot.sql
- fonoteka.go/parity/schema_diff_test.go
- fonoteka.go/parity/rollback_isolation_full_test.go
autonomous: true
requirements: [DATA-09, DATA-11, CLI-03]
must_haves:
truths:
- "The remaining 8 models (CollectionInvitation, PendingInvitationRegistration, CsvImport, CsvImportRow, Notification, WishlistSubscription, WishlistDigestQueue, Settings) and their migrations exist, completing the 25-model port (DATA-09)"
- "notifications, wishlist_subscriptions and wishlist_digest_queue have no FK constraints on user_id/collection_id, matching PHP exactly — not fixed as an oversight (RESEARCH.md Open Question 3)"
- "Settings is a dedicated golem15_fonoteka_settings singleton-row table with a typed search_use_typesense BOOLEAN column, not Winter's generic system_settings mechanism (RESEARCH.md Open Question 2, resolved by the user)"
- "A committed, live-verified PHP schema snapshot exists in fonoteka.go, and a testcontainers test migrates every Go migration set and diffs information_schema/pg_catalog against it with a small commented allow-list — the D-02 proof (DATA-09)"
- "A test-only fixture plugin (not loaded by production app/app.go) extends golem15.fonoteka's Album lifecycle through its own Boot() GORM callback and adds a companion column via its own gormigrate set, without editing models/album.go and without registering that migration in fonoteka updates.All() (DATA-11, ARCHITECTURE.md Pattern 3b/3c, CONTEXT.md discretion)"
- "summer migrate:rollback --plugin=fonoteka rolls back only Fonoteka's own last migration, leaving golem15.user's history and earlier Fonoteka migrations untouched, now that the full 20+ migration schema exists (CLI-03)"
artifacts:
- path: fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go
provides: "7-8 migrations completing the 25-model schema"
- path: fonoteka.go/parity/testdata/php_schema_snapshot.sql
provides: "Committed golden schema snapshot for the D-02 diff test"
- path: fonoteka.go/parity/schema_diff_test.go
provides: "TestSchemaMatchesPHPSnapshot"
- path: fonoteka.go/parity/fixtureplugin/plugin.go
provides: "Test-only plugin Boot() registering a GORM callback on Album create/save"
- path: fonoteka.go/parity/fixtureplugin/migrations.go
provides: "Fixture plugin's own gormigrate set adding demo_extension_note on the test DB only"
key_links:
- from: fonoteka.go/parity/fixtureplugin/plugin.go
to: fonoteka.go/plugins/golem15/fonoteka/models/album.go
via: "Boot() registers a GORM callback on Album without editing models/album.go; TestCrossPluginCallback is the only activator"
pattern: "Callback\\(\\)\\.Create\\(\\)"
- from: fonoteka.go/parity/schema_diff_test.go
to: fonoteka.go/parity/testdata/php_schema_snapshot.sql
via: "diff information_schema against the committed snapshot"
pattern: "php_schema_snapshot\\.sql"
---
<objective>
Finish the 25-model port (the remaining 8 models with no dense casts/relations), prove D-02's schema-equality claim with an automated diff against a committed live-verified PHP snapshot, demonstrate DATA-11's cross-plugin lifecycle-and-schema extension with a test-only fixture plugin, and re-verify CLI-03's rollback isolation now that the full migration set exists.
Purpose: this plan is the phase's proof point — every earlier plan built pieces, this one proves the whole schema equals PHP's and that the extension mechanisms (callback registry + companion migration) actually work end to end, not just in isolation.
Output: 8 remaining models + their migrations; a committed PHP schema snapshot + diff test; a test-only fixture plugin for DATA-11; a rollback-isolation test against the full production schema.
</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/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md
@.planning/phases/05-data-layer-full-fidelity/05-02-SUMMARY.md
</context>
<interfaces>
<!-- Contracts this plan's tasks consume -->
From fonoteka.go's models/updates/classes registries (Plan 05-01, both plugins):
```go
func Register(models ...any); func All() []any
func Register(ms ...*gormigrate.Migration); func All() []*gormigrate.Migration
func RegisterHook(fn func(*gorm.DB) error); func RegisterHooks(gdb *gorm.DB) error
```
From fonoteka.go/parity/migrate_test.go (the existing isolation-test shape this plan's Task 3 re-runs against the full schema):
```go
func parityDB(t *testing.T) *sql.DB
func gormOnSharedPool(t *testing.T, db *sql.DB) *gorm.DB
func activateAppPlugins(t *testing.T) (*backpack.App, []party.Plugin)
```
From summercms.go/lagoon (unchanged public API this plan's tests call):
```go
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error
func RollbackLast(gdb *gorm.DB, plugins []party.Plugin, pluginID string) error
func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error)
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1 (fonoteka.go): Remaining 8 models and their migrations — invitations, CSV import, notifications, wishlist, settings</name>
<files>fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go, fonoteka.go/plugins/golem15/fonoteka/models/{collection_invitation,pending_invitation_registration,csv_import,csv_import_row,notification,wishlist_subscription,wishlist_digest_queue,settings}.go, summercms.go/lagoon/validate.go</files>
<read_first>
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.8/create_collection_invitations_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.8/create_pending_invitation_registrations_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.2/create_csv_import_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.6/add_csv_import_mode.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.1/create_notifications_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.2/create_wishlist_subscriptions_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.3/create_wishlist_digest_queue_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CollectionInvitation.php (computed status accessor)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 7, 9, 10, 12, 18, 19, 24, 25; Open Questions 2 and 3; Squashed Migration List rows 9-17)
</read_first>
<action>
Create `updates/20_remaining.go` (package `updates`) with migrations for: `create_collection_invitations` (`golem15_fonoteka_collection_invitations`: `collection_id`, `email`, `token_hash UNIQUE`, `invited_by`, `expires_at`, `accepted_at`, `accepted_by`, `revoked_at`, timestamps; FK `collection_id`->collections, `invited_by`/`accepted_by`->users), `create_pending_invitation_registrations` (`user_id UNIQUE`, `invitation_id`, timestamps), `create_csv_import_tables` (`golem15_fonoteka_csv_imports`: `user_id`, `collection_id`, `match_job_id`, `import_job_id`, `status`, `import_mode`, `column_map` text (jsonable), `original_filename`, `storage_path`, `row_count`, `error_message`, timestamps; `golem15_fonoteka_csv_import_rows`: `csv_import_id`, `row_index`, `status`, `raw_json`, `artist`, `title`, `candidates_json`, `selected_discogs_id`, `matched_album_id`, `draft_json`, `error_code`, `error_message`, timestamps, `UNIQUE(csv_import_id, row_index)`), `create_notifications` (`golem15_fonoteka_notifications`: `user_id`, `type`, `payload` text, `read_at`, timestamps — read the PHP migration and confirm it has no `->foreign()` call on `user_id`; add none), `create_wishlist_subscriptions` (`user_id`, `collection_id`, `ws_enabled`, `email_enabled`, `subscribed_at`, timestamps, `UNIQUE(user_id, collection_id)` — no FK on either column), `create_wishlist_digest_queue` (`user_id`, `collection_id`, `item_count`, timestamps, `UNIQUE(user_id, collection_id)` — no FK on either column). Verify every column type/default against the listed PHP files directly rather than guessing. End with a package `init()` registering the whole slice with `updates.Register(...)`.
Add `create_fonoteka_settings` to the same file (RESEARCH.md Open Question 2, resolved: dedicated typed table): `golem15_fonoteka_settings (id SERIAL PRIMARY KEY, search_use_typesense BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, seeded with one singleton row (`id = 1`) via an idempotent existence-guarded insert, same style as the earlier genre/artist seed migrations.
Create `models/settings.go`: `Settings{ID uint; SearchUseTypesense bool; CreatedAt, UpdatedAt time.Time}`, `TableName() "golem15_fonoteka_settings"`, `Fillable() []string{"search_use_typesense"}`, `Rules() map[string]string{"search_use_typesense": "boolean"}`. Add one small case to `lagoon/validate.go`'s rule-translation table for the bare `boolean` token (treat it as a type-check no-op, since Go's `bool` field type already enforces this) — this is the only touch of that file in this plan.
Create the 7 remaining model files: `CollectionInvitation` (row 7, plus a `Status() string` method porting the PHP accessor's accepted/revoked/expired/pending precedence verbatim), `PendingInvitationRegistration` (row 18), `CsvImport`/`CsvImportRow` (rows 9/10, jsonable-typed columns via Plan 05-02's `lagoon.Jsonable[T]`), `Notification` (row 12, `Payload lagoon.Jsonable[map[string]any]`, `UserID uint` with no FK-generating GORM tag), `WishlistSubscription` (row 25, no FK), `WishlistDigestQueue` (row 24, no FK). Every file registers itself via `init()`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./...</automated>
</verify>
<acceptance_criteria>
- A new migration test migrates the full `updates.All()` slice for `golem15.fonoteka` and asserts all 8 remaining tables exist, then rolls each back individually.
- `grep -n "REFERENCES" fonoteka.go/plugins/golem15/fonoteka/updates/20_remaining.go` shows no `REFERENCES` clause on the `notifications`, `wishlist_subscriptions`, or `wishlist_digest_queue` table definitions.
- Exactly one row exists in `golem15_fonoteka_settings` after migrate, and a second `Migrate()` call does not insert a duplicate.
</acceptance_criteria>
<done>All 8 remaining models and migrations exist; the FK-omission and Settings-storage decisions match RESEARCH.md's resolved open questions exactly.</done>
</task>
<task type="auto">
<name>Task 2 (fonoteka.go): D-02 schema-diff proof — committed PHP snapshot + TestSchemaMatchesPHPSnapshot</name>
<files>fonoteka.go/parity/testdata/php_schema_snapshot.sql, fonoteka.go/parity/schema_diff_test.go</files>
<read_first>
fonoteka.go/parity/migrate_test.go (parityDB/gormOnSharedPool/activateAppPlugins/dsnWithDB helpers, dedicated-database-per-test shape lines 95-121)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("D-02 Verified: PHP Migrations Run Cleanly on Postgres" — the exact reproducible docker+winter:up+pg_dump method; "Squashed Migration List" for the full expected table set)
</read_first>
<action>
Reproduce the exact method RESEARCH.md documents as already verified this research session: run a scratch `postgres:16-alpine` container, run the real PHP app's `php artisan winter:up` against it (from the sibling PHP repo at `/media/nvme/dev/golem15/fonoteka`), then `pg_dump --schema-only` filtered to `golem15_fonoteka_*`, `system_files`, `users`, `golem15_user_organisations` tables, normalizing owner/privilege noise (`--no-owner --no-privileges`). Commit the resulting SQL as `fonoteka.go/parity/testdata/php_schema_snapshot.sql` — this is a one-time, offline generation step; CI/tests never need PHP installed.
Create `parity/schema_diff_test.go`'s `TestSchemaMatchesPHPSnapshot`: spin up a dedicated ICU pl-PL database (same pattern as `migrate_test.go`'s `TestRollbackLastIsolatesFonoteka`), run `lagoon.Migrate(gdb, plugins)` for both `golem15.user` and `golem15.fonoteka` plus the framework's `system_files` set, then load the committed snapshot into a second dedicated database (`psql < php_schema_snapshot.sql` via `exec.Command` or by parsing the SQL directly), and diff both databases' `information_schema.columns`/`information_schema.table_constraints`/`pg_indexes` for the shared table set, failing on any unexplained difference (missing table, missing column, type mismatch, missing/extra unique or FK constraint). Keep a small, explicitly commented allow-list `var allowedDiffs = map[string]string{...}` for genuinely intended differences (e.g. `golem15_fonoteka_settings` existing in the Go schema with no PHP equivalent, since Settings uses a dedicated table per the resolved open question rather than Winter's generic `system_settings` — document exactly why each allow-listed entry exists, per the scope_reduction/gap-handling discipline: this is a deliberate decision, not a silently-tolerated gap). Run `lagoon.Migrate` against the production plugins only (`golem15.user` + `golem15.fonoteka` + framework `system_files`). Do not activate the DATA-11 fixture plugin in this test. Do not add `demo_extension_note` (or any fixture-only column) to `allowedDiffs` — that column must not exist on the production schema (D-02).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./parity/... && go test ./parity/... -run TestSchemaMatchesPHPSnapshot</automated>
</verify>
<acceptance_criteria>
- `fonoteka.go/parity/testdata/php_schema_snapshot.sql` is committed and non-empty, covering all 25 Fonoteka tables plus `system_files`/`users`/`golem15_user_organisations`.
- `TestSchemaMatchesPHPSnapshot` fails if a column is deliberately dropped from a migration in a scratch branch (spot-check this manually during development, then restore before finishing).
- Every entry in `allowedDiffs` has an inline comment naming the specific decision (D-01/D-02/RESEARCH Open Question 2) that justifies it.
- `grep -n "demo_extension_note" fonoteka.go/parity/schema_diff_test.go fonoteka.go/plugins/golem15/fonoteka/updates` returns no matches (production schema and its allow-list never mention the fixture column).
</acceptance_criteria>
<done>The committed PHP schema snapshot exists and `TestSchemaMatchesPHPSnapshot` proves the full Go schema matches it modulo a small, justified, commented allow-list.</done>
</task>
<task type="auto">
<name>Task 3 (fonoteka.go): DATA-11 test-only fixture plugin; CLI-03 rollback-isolation re-verification against the full production schema</name>
<files>fonoteka.go/parity/fixtureplugin/plugin.go, fonoteka.go/parity/fixtureplugin/migrations.go, fonoteka.go/parity/cross_plugin_callback_test.go, fonoteka.go/parity/rollback_isolation_full_test.go</files>
<read_first>
.planning/research/ARCHITECTURE.md (Pattern 3b GORM callback registry, Pattern 3c companion struct + own migration)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Pattern: Cross-plugin lifecycle extension without editing the owning model" code example)
fonoteka.go/parity/migrate_test.go (TestRollbackLastIsolatesFonoteka — the exact shape to re-run against the now-full migration set; activateAppPlugins/parityDB/gormOnSharedPool helpers)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (Claude's Discretion: a fixture plugin in tests is acceptable if no real Płytarium case fits)
fonoteka.go/app/app.go (production PluginIDs — must not gain the fixture)
fonoteka.go/plugins.gen.go (generated blank-imports — must not gain the fixture)
summercms.go/party/registry.go (Register is process-wide; the fixture must not call it from init())
summercms.go/pact/capabilities.go (HasMigrations)
</read_first>
<action>
Demonstrate criterion 5's two halves with a test-only fixture plugin, not an intra-plugin demo inside golem15.fonoteka (CONTEXT.md discretion: a fixture plugin in tests is acceptable if no real Płytarium case fits). Create package `fixtureplugin` under `fonoteka.go/parity/fixtureplugin/` (same app module, not under `plugins/golem15/` so `summer.yaml`/`plugins.gen.go` never pick it up). `plugin.go` implements `party.Plugin` and `pact.HasMigrations`: `ID()` returns `"parity.fixtureplugin"`, `Requires()` returns `[]string{"golem15.fonoteka"}`. Do NOT call `party.Register` from `init()` and do NOT blank-import this package from `app/app.go` or `plugins.gen.go` — a process-wide Register would leak into other tests. `Boot(app *backpack.App) error` looks up `*gorm.DB`, then registers a GORM callback `gdb.Callback().Create().After("gorm:create").Register("fixtureplugin:demo_album_created", ...)` (and the matching Update hook if save also needs it) that type-asserts `tx.Statement.Schema.ModelType == reflect.TypeOf(models.Album{})` before incrementing an exported atomic counter; it must not edit `models/album.go`. `migrations.go` returns this plugin's own `[]*gormigrate.Migration` with `demo_add_album_notes_extension_column` running `ALTER TABLE golem15_fonoteka_albums ADD COLUMN demo_extension_note TEXT` and a Rollback that drops it. That slice is returned from `Migrations()` only — it is never passed to fonoteka's `updates.Register` / `updates.All()`. Query the companion column through a fixture-local struct embedding `models.Album` (ARCHITECTURE.md Pattern 3c), never by adding the field to `models/album.go`.
Create `parity/cross_plugin_callback_test.go`'s `TestCrossPluginCallback`: use the existing `parityDB`/`gormOnSharedPool`/`activateAppPlugins` helpers to migrate the production plugins, then construct `&fixtureplugin.Plugin{}` in-process (no `party.Register`), call `Boot` against the app whose `*gorm.DB` is published, and `lagoon.Migrate(gdb, []party.Plugin{fix})` so the companion column exists on this test DB only. Insert an Album, assert the callback fired exactly once, also create a Genre and assert the counter does not increment, and assert `demo_extension_note` exists on this database via `information_schema`. A production-only migrate (Task 2's schema-diff test) must not see that column.
Add `parity/rollback_isolation_full_test.go` re-running `TestRollbackLastIsolatesFonoteka`'s exact shape (dedicated ICU pl-PL database, migrate both production plugins' full sets, call `lagoon.RollbackLast(gdb, plugins, "golem15.fonoteka")`) against the now-complete Fonoteka `updates.All()` only — the fixture plugin's companion migration is not in that set. Assert only the single last-registered Fonoteka migration is rolled back, `golem15.user`'s history table is completely untouched, and every earlier Fonoteka migration/table survives.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./parity/... -run TestCrossPluginCallback && go test ./parity/... -run TestRollbackIsolatesFonotekaFullSchema</automated>
</verify>
<acceptance_criteria>
- The fixture callback fires exactly once per `Album` create and zero times for any other model's create (assert both, e.g. by also creating a `Genre` and confirming the counter does not increment).
- `grep -n "demo_extension_note" fonoteka.go/plugins/golem15/fonoteka/models/album.go` returns no match; `grep -n "demo_extension_note" fonoteka.go/plugins/golem15/fonoteka/updates` returns no match (the companion column is not in fonoteka's production migration set).
- `grep -n "fixtureplugin" fonoteka.go/app/app.go fonoteka.go/plugins.gen.go` returns no match; `grep -n "party.Register" fonoteka.go/parity/fixtureplugin` returns no match.
- The rollback-isolation test against the full production schema passes: `golem15.user`'s migration count and table set are byte-identical before and after the call.
</acceptance_criteria>
<done>A test-only fixture plugin extends Album's lifecycle and schema without editing fonoteka's own model file or production migration set; CLI-03's isolation guarantee is re-proven against the complete Phase 5 production schema.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| schema-diff test -> committed snapshot | the snapshot is the ground truth for "matches PHP" — it must be regenerated only via the documented reproducible method, never hand-edited to make a failing diff pass |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-17 | Tampering (test integrity) | `allowedDiffs` allow-list in `TestSchemaMatchesPHPSnapshot` | mitigate | Every entry requires an inline comment naming the specific decision that justifies it; Plan 05-06's review checklist re-checks this list is not being used to paper over an unintended gap |
| T-05-18 | Repudiation / Tampering | `notifications`/`wishlist_subscriptions`/`wishlist_digest_queue` lacking FK constraints | accept | Deliberate parity match with PHP (RESEARCH.md Open Question 3); documented in the migration's own comment so it is never "fixed" by a later, uninformed change |
| T-05-19 | Elevation of Privilege | fixture-plugin cross-plugin callback registration | accept | Test-only fixture plugin, not loaded by production `app/app.go`; gated behind `models.Album` type-assertion so it cannot fire for any other model; not wired into any HTTP path this phase |
</threat_model>
<verification>
`cd fonoteka.go && go vet ./... && go test ./...` (testcontainers Postgres) covering the remaining 8 models, the schema-diff test, the test-only fixture plugin, and the full-schema rollback-isolation test.
</verification>
<success_criteria>
- All 25 Płytarium models and their squashed migration sets exist and are covered by `updates.All()`.
- `TestSchemaMatchesPHPSnapshot` is green against a committed, reproducibly-generated snapshot with a small, justified allow-list and no fixture-only columns.
- A test-only fixture plugin's companion migration and Boot() callback demonstrate DATA-11's two extension halves without editing the owning model's file or fonoteka `updates.All()`.
- `migrate:rollback --plugin=fonoteka` isolation holds against the complete production schema.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-05-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,195 @@
---
phase: 05-data-layer-full-fidelity
plan: 06
type: execute
wave: 5
depends_on: ["05-05"]
files_modified:
- fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_fuzz_test.go
- fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service_fuzz_test.go
- fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service_fuzz_test.go
- summercms.go/lagoon/fill_fuzz_test.go
- summercms.go/lagoon/hidden_marshal_test.go
- fonoteka.go/plugins/golem15/fonoteka/classes/collection_public_token_test.go
- fonoteka.go/plugins/golem15/fonoteka/classes/attach_smoke_test.go
- .planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md
autonomous: true
requirements: [DATA-06, DATA-07, DATA-09]
must_haves:
truths:
- "lagoon.Fill has its own framework-level fuzz test on a fixture model, asserting random extra/unknown keys are never set on the struct (D-05, D-06, D-07)"
- "AlbumWriteService, CollectionWriteService and all four credential write paths are fuzzed against real Postgres with random extra and server-owned keys, asserting nothing outside each service's fill boundary is ever persisted (D-07)"
- "A test marshals every model registered in models.All() (both plugins) and asserts every name in that model's Hidden() is absent from the JSON output (D-08)"
- "Soft-deleting then attempting to recreate a Collection with the same public_token reproduces PHP's actual plain-UNIQUE-constraint behavior, not a partial-index improvement (success criterion 5, RESEARCH.md soft-delete+unique audit)"
- "A security-review pass covers the fill and encryption boundaries specifically, with each finding mapped to a passing test or an accepted/documented risk"
artifacts:
- path: .planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md
provides: "Security review pass mapping every T-05-xx threat across all 5 prior plans to a passing test or an explicit accept/transfer rationale"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_fuzz_test.go
to: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
via: "fuzz target calls SaveAlbum with random extra/server-owned keys"
pattern: "func FuzzSaveAlbum"
- from: summercms.go/lagoon/hidden_marshal_test.go
to: fonoteka.go/plugins/golem15/fonoteka/models/registry.go
via: "test imports both plugins' models.All() and marshals every registered type"
pattern: "models\\.All\\(\\)|user/models\\.All\\(\\)"
---
<objective>
Close the phase with full unit/fuzz coverage of everything Plans 05-01 through 05-05 built, plus the security-review pass CONTEXT.md names as mandatory for this phase's mass-assignment and encryption boundaries. This is the last plan of the phase per the project's lean-mode workflow rule — earlier plans carried smoke tests but were never blocked on full coverage; this plan is what closes that gap.
Purpose: prove, not just assert, that D-05/D-06/D-07's two-layer fillable boundary actually rejects unknown/server-owned keys under fuzzing against real Postgres, that D-08's hidden discipline holds across every one of the 25+ registered models (not just the ones spot-checked while writing them), and that the one real soft-delete+unique interaction behaves exactly like PHP's.
Output: fuzz tests for `lagoon.Fill` and every fill-boundary write service named in D-07 (Album, Collection, the 4 credential models); a hidden-marshal test over every registered model in both plugins; the delete-then-recreate `public_token` behavioral test; `05-SECURITY-REVIEW.md`.
</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/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md
@.planning/phases/05-data-layer-full-fidelity/05-02-SUMMARY.md
@.planning/phases/05-data-layer-full-fidelity/05-03-SUMMARY.md
@.planning/phases/05-data-layer-full-fidelity/05-04-SUMMARY.md
@.planning/phases/05-data-layer-full-fidelity/05-05-SUMMARY.md
</context>
<interfaces>
<!-- Contracts every prior plan in this phase shipped, consumed here for verification only -->
```go
// lagoon (Plan 05-01/05-03)
func Fill(model any, allowed []string, requested map[string]any, production bool) error
type HasHidden interface { Hidden() []string }
type Encrypted struct{ /* unexported */ }
func (e Encrypted) Reveal() string
// fonoteka plugin classes (Plan 05-02)
var AlbumFillFields []string
func SaveAlbum(ctx context.Context, gdb *gorm.DB, album *models.Album, requested map[string]any, artistIDs []uint) error
var CollectionFillFields []string
func SaveCollection(ctx context.Context, gdb *gorm.DB, collection *models.Collection, requested map[string]any) error
// models registries, both plugins (Plan 05-01)
func All() []any
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1 (summercms.go, fonoteka.go): Fill boundary fuzz — lagoon.Fill framework fuzz + Album/Collection/4-credential-model service-level fuzz against real Postgres</name>
<files>summercms.go/lagoon/fill_fuzz_test.go, fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_fuzz_test.go, fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service_fuzz_test.go, fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service_fuzz_test.go</files>
<read_first>
summercms.go/lagoon/fill.go (Plan 05-01)
fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, collection_write_service.go (Plan 05-02)
fonoteka.go/plugins/golem15/fonoteka/models/{user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go (Plan 05-03)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (D-07's exact wording: "fuzzed against real Postgres with random extra and server-owned keys, asserting nothing outside the list is persisted")
fonoteka.go/parity/migrate_test.go (activateAppPlugins/parityDB harness this fuzz test reuses)
</read_first>
<action>
In `lagoon/fill_fuzz_test.go`: add `func FuzzFill(f *testing.F)` seeding with a handful of known-shape maps (some keys allowed, some not, some `nil` values, some non-string keys coerced) against a small fixture struct declared in the test file; the fuzz body asserts the fixture's non-allow-listed fields are always left at their zero value after `Fill` runs, regardless of what the fuzzer threw at the `requested` map, and that `Fill` never panics on any input (including deeply nested/weird `any` values, empty maps, and a `nil` map).
In `classes/album_write_service_fuzz_test.go`: add `func FuzzSaveAlbum(f *testing.F)` — for each fuzzed input (a `map[string]any` including random keys drawn from a mix of real column names outside `AlbumFillFields` — specifically always include `collection_id` and `market_price_source` as adversarial server-owned keys per D-07's explicit callout — plus fuzzer-generated garbage keys), call `SaveAlbum` against the real testcontainers Postgres (reuse `activateAppPlugins`/`parityDB`), then re-load the saved row directly via `SELECT` and assert `collection_id` and `market_price_source` still hold their pre-fuzz values, never the fuzzed input's values, and that no fuzzer-generated garbage key ever appears as a real column write (this last assertion is necessarily structural: assert the row's *actual* fillable-column values match only what `AlbumFillFields` allowed through, by comparing against a parallel in-memory application of `Fill` with the same inputs). Mirror the same shape in `collection_write_service_fuzz_test.go` for `SaveCollection`/`CollectionFillFields`.
In `classes/credential_write_service_fuzz_test.go`: for each of the 4 credential models (`UserAiCredential`, `OrgAiCredential`, `UserDiscogsCredential`, `OrgDiscogsCredential`), fuzz a save path using each model's own `Fillable()` as the allow-list (per D-07, "at minimum Album, Collection and the four credential models" — these four have no dedicated write-service file per Plan 05-03, so fuzz `lagoon.Fill` + a direct `tx.Save` call for each, asserting the `Encrypted`-typed `api_key`/`token` column round-trips correctly under fuzzed non-credential keys and that no adversarial key (e.g. a fuzzed `organisation_id`/`user_id` override attempt) ever changes the row's owner FK away from what the test explicitly set before calling `Fill`).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./lagoon/... -fuzz=FuzzFill -fuzztime=20s && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go test ./plugins/golem15/fonoteka/classes/... -fuzz=FuzzSaveAlbum -fuzztime=20s</automated>
</verify>
<acceptance_criteria>
- `FuzzFill` runs for at least 20s with zero crashers/panics and zero corpus entries where a non-allowed field was set.
- `FuzzSaveAlbum`/`FuzzSaveCollection` each run for at least 20s against real Postgres with zero crashers, and zero cases where `collection_id`/`market_price_source` (or Collection's excluded fields) changed from their pre-fuzz value.
- The 4 credential fuzz tests each assert the row's FK owner column is never altered by a fuzzed key.
</acceptance_criteria>
<done>`lagoon.Fill` and all 6 named write paths (Album, Collection, 4 credential models) are fuzz-tested against real Postgres with zero found violations of the fillable boundary.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2 (summercms.go, fonoteka.go): Hidden-marshal test over every registered model; Collection.public_token delete-then-recreate behavior</name>
<files>summercms.go/lagoon/hidden_marshal_test.go, fonoteka.go/plugins/golem15/fonoteka/classes/collection_public_token_test.go</files>
<read_first>
summercms.go/lagoon/fill.go (HasHidden)
fonoteka.go/plugins/golem15/fonoteka/models/registry.go, fonoteka.go/plugins/golem15/user/models/registry.go (Plan 05-01, both All() functions)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Soft-delete + unique audit" section — golem15_fonoteka_collections.public_token is the one real target, plain UNIQUE, no partial index)
</read_first>
<action>
In `lagoon/hidden_marshal_test.go` (placed in `lagoon` so it can import both plugin modules as test-only dependencies, or in a small dedicated test package under `fonoteka.go/parity` if importing both plugins from `summercms.go`'s test tree is not viable given the module boundary — pick whichever avoids a circular/awkward import and document the choice in a comment): iterate every value in `fonoteka.go/plugins/golem15/fonoteka/models.All()` and `fonoteka.go/plugins/golem15/user/models.All()`, type-assert each against `lagoon.HasHidden`, and for every one that implements it, populate a zero-value instance's hidden fields with a distinctive sentinel string (via reflection, matching the field to its `gorm:"column:<name>"` tag), `json.Marshal` it, and assert the sentinel string is never present in the output. Also assert every field carrying a `json:"-"` tag is likewise absent, as an independent structural check (parse the struct's own field tags via `reflect.Type`, not just `Hidden()`'s stated list) — this catches a `Hidden()` implementation drifting out of sync with its own `json:"-"` tags.
In `classes/collection_public_token_test.go`: soft-delete a `Collection` row that has a `PublicToken` set, then attempt to create a second `Collection` with the identical literal `public_token` value, and assert this reproduces PHP's actual behavior — a real Postgres unique-constraint violation (the plain `UNIQUE` constraint applies regardless of `deleted_at`, per RESEARCH.md's audit) — not a fabricated "success" that a partial index would have allowed. Also test the inverse: after the first Collection is *hard*-deleted (`Unscoped().Delete`), the same `public_token` value is now free to reuse.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./lagoon/... -run TestHiddenNeverMarshals && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go test ./plugins/golem15/fonoteka/classes/... -run TestCollectionPublicTokenDeleteThenRecreate</automated>
</verify>
<acceptance_criteria>
- The hidden-marshal test enumerates and checks every model in both `models.All()` slices (assert the enumerated count matches the expected 25+ model total, so silently skipping a model — e.g. one that fails the `HasHidden` type assertion by mistake — is itself a caught regression).
- `TestCollectionPublicTokenDeleteThenRecreate` asserts a real constraint-violation error is returned after a soft-delete, and asserts success after a hard-delete of the same row.
</acceptance_criteria>
<done>Every registered model's hidden columns are provably unmarshalable, and the one real soft-delete+unique interaction matches PHP's actual (not "improved") behavior.</done>
</task>
<task type="auto">
<name>Task 3 (fonoteka.go, .planning): Attachment lifecycle smoke test; security-review pass over the fill and encryption boundaries</name>
<files>fonoteka.go/plugins/golem15/fonoteka/classes/attach_smoke_test.go, .planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md</files>
<read_first>
summercms.go/lagoon/attach/file.go, thumb.go (Plan 05-04)
fonoteka.go/plugins/golem15/fonoteka/models/album.go, collection.go (MorphName, Plan 05-04)
.planning/phases/05-data-layer-full-fidelity/05-01-PLAN.md through 05-05-PLAN.md's `<threat_model>` blocks (every T-05-xx entry across all 5 prior plans)
</read_first>
<action>
Add an `attach_smoke_test.go` covering Collection's ordered `photos` and single `image` end to end against a `memblob` bucket: create a Collection, attach 3 photo `File` rows with distinct `sort_order`, attach 1 image `File` row, read them back ordered, call `.Thumb(200,200,"crop")` on the image and assert the returned URL contains the exact `thumb_<id>_200_200_0_0_crop.<ext>` filename, soft-delete the Collection and assert the files/blobs survive, then force-delete and assert the rows are gone and (per Plan 05-04's documented two-phase contract) the blobs are removed after the transaction commits.
Write `.planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md`: list every `T-05-*` threat ID from all 5 prior plans' `<threat_model>` blocks in one table, and for each, name the specific test (from this plan or an earlier one) that proves the mitigation holds, or restate the accept/transfer rationale verbatim from its originating plan. Focus the narrative specifically on the fill and encryption boundaries per CONTEXT.md's phase-boundary text ("apply the security-review agent... security review can grep" for `Reveal` calls): explicitly grep the full `fonoteka.go` and `summercms.go` trees for `.Reveal()` call sites and list every one found, confirming none appear in a logging statement, a `Serialize*` function, or any path reachable from marshal/String/GoString; explicitly grep for `Association(` to confirm no pivot write ever uses Association Mode; explicitly grep for `AutoMigrate` to confirm the zero-tolerance rule held across all 5 plans' new code.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go test ./plugins/golem15/fonoteka/classes/... -run TestAttachSmoke && grep -rn "AutoMigrate" plugins/ | wc -l</automated>
</verify>
<acceptance_criteria>
- `TestAttachSmoke` passes, exercising the full photo/image/thumb/soft-delete/force-delete lifecycle against `memblob`.
- `05-SECURITY-REVIEW.md` maps every `T-05-01` through the highest-numbered threat ID across all 5 prior plans to a named passing test or a restated accept/transfer rationale — none left unmapped.
- `grep -rn "AutoMigrate" fonoteka.go/plugins summercms.go/lagoon` returns zero matches.
- `grep -rln "\.Reveal()" fonoteka.go summercms.go` results are all enumerated by name in `05-SECURITY-REVIEW.md`, each confirmed not reachable from a logging or serialization path.
</acceptance_criteria>
<done>The attachment lifecycle is smoke-tested end to end; every threat named across the phase's 5 prior plans is mapped to a passing test or a restated, deliberate risk acceptance in a committed security review document.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| this plan's tests -> every earlier plan's security-load-bearing code | this is the phase's closing verification pass, not new production code — its own risk surface is limited to test-code correctness |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-20 | Tampering | Fuzz corpus could under-explore the input space and miss a real violation | accept | 20s minimum fuzz time per target plus explicit adversarial seed corpus entries (collection_id, market_price_source, organisation_id overrides) named directly in the task rather than left to chance discovery |
| T-05-21 | Information Disclosure | The hidden-marshal test itself could silently skip a model if a future model forgets to implement HasHidden | mitigate | The test asserts the enumerated model count against the expected total, turning a silently-skipped model into a failing test rather than a false pass |
| T-05-22 | Repudiation | Security review completeness | mitigate | `05-SECURITY-REVIEW.md` is required to map every T-05-xx ID from all 5 prior plans by name — an unmapped ID is a review gap, not an accepted risk, and must be caught before phase close |
</threat_model>
<verification>
Full suite in both repos: `cd summercms.go && go vet ./... && go test ./...` (including `-fuzz` targets for at least 20s each) and `cd ../fonoteka.go && go vet ./... && go test ./...` (testcontainers Postgres). `05-SECURITY-REVIEW.md` committed and cross-checked against every prior plan's `<threat_model>` block.
</verification>
<success_criteria>
- `lagoon.Fill` and all 6 D-07-named write paths are fuzz-tested against real Postgres with zero found fillable-boundary violations.
- Every registered model (25+ across both plugins) passes the hidden-marshal check.
- The one real soft-delete+unique interaction (`Collection.public_token`) matches PHP's actual constraint behavior.
- The attachment lifecycle is smoke-tested end to end.
- `05-SECURITY-REVIEW.md` maps every threat ID from all 5 prior plans to a passing test or a restated acceptance rationale.
- `go vet ./...` and `go test ./...` are green in both repos.
</success_criteria>
<output>
Create `.planning/phases/05-data-layer-full-fidelity/05-06-SUMMARY.md` when done
</output>

View File

@@ -1,8 +1,8 @@
---
phase: 5
slug: data-layer-full-fidelity
status: draft
nyquist_compliant: false
status: planned
nyquist_compliant: true
wave_0_complete: false
created: 2026-09-18
---
@@ -21,7 +21,7 @@ created: 2026-09-18
| **Config file** | none — plain `func TestX(t *testing.T)`; `TestMain` pattern per `lagoon/postgres_test.go` and `fonoteka.go/parity` |
| **Quick run command** | `go vet ./... && go test ./... -short` (run in the repo the task writes to) |
| **Full suite command** | `go test ./...` in `summercms.go` and in `../fonoteka.go` (real Postgres via testcontainers) |
| **Estimated runtime** | ~20 s quick, ~120 s full |
| **Estimated runtime** | ~20 s quick, ~120-180 s full (Plan 05-06 fuzz targets add ~2-3 min more) |
---
@@ -29,27 +29,40 @@ created: 2026-09-18
- **After every task commit:** Run `go vet ./... && go test ./... -short`
- **After every plan wave:** Run `go test ./...` in both repos
- **Before `/gsd:verify-work`:** Full suite green in both repos, including the D-02 schema-diff test
- **Max feedback latency:** 120 seconds
- **Before `/gsd:verify-work`:** Full suite green in both repos, including the D-02 schema-diff test and Plan 05-06's fuzz/security-review pass
- **Max feedback latency:** 120 seconds (excluding explicit `-fuzz=...s` runs, which are bounded by their own `-fuzztime`)
---
## Per-Task Verification Map
Task IDs are filled in by the planner; the requirement → test mapping below is the contract.
Note: Plan 05-01's original Task 1 (models-leaf restructure) was split into a probe/gate task and a restructure task during revision; the write-path/read-path lagoon-primitive tasks that follow it shifted from Task 2/3 to Task 3/4. Task IDs below reflect the revised numbering.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| TBD | TBD | TBD | DATA-03 | — | Cascading soft delete runs in one transaction | integration | `go test ./plugins/golem15/fonoteka/... -run TestCollectionBeforeDeleteCascadesAlbums` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-04 | — | N/A | integration | `go test ./plugins/golem15/fonoteka/... -run TestAlbumArtistsOrderRoundTrip` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-05 | — | Untranslatable rule fails loudly; 422 map Laravel-shaped | unit + integration | `go test ./lagoon/... -run TestValidate` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-06 | TBD | Unknown / server-owned keys never persisted | integration (fuzz) | `go test ./lagoon/... -run TestFill` and `go test ./plugins/golem15/fonoteka/... -run TestAlbumWriteServiceFillFuzz` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-07 | TBD | Ciphertext at rest, redacted marshal, plaintext only via `Reveal()`; money never `float64` | unit + integration | `go test ./lagoon/... -run TestEncrypted`; `go test ./plugins/golem15/fonoteka/models/... -run TestMoneyString` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-08 | — | Force delete removes blobs only after commit | unit + integration | `go test ./lagoon/attach/... -run 'TestThumbFilename|TestFileLifecycle'` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-09 | — | N/A | integration | `go test ./parity/... -run TestSchemaMatchesPHPSnapshot` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-10 | — | N/A | unit | `go test ./lagoon/... -run TestPaginate` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | DATA-11 | — | N/A | integration | `go test ./plugins/... -run TestCrossPluginCallback` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | CLI-03 | — | Rollback touches only the named plugin's history | integration | `go test ./cmd/summer/... -run TestRollbackScopedToPlugin` | ❌ W0 | ⬜ pending |
| 05-01-T3 | 05-01 | 1 | DATA-03 | T-05-02 | `lagoon.WithSoftDeleteCascade` framework primitive: cascading soft delete runs in one transaction (real cascade wiring lands in 05-02) | integration | `go test ./lagoon/... -run TestWithSoftDeleteCascade` | ❌ W0 | ⬜ pending |
| 05-02-T2 | 05-02 | 2 | DATA-03 | T-05-24 | `Collection.BeforeDelete` cascades a real soft-delete of every Album inside the same transaction, via the Plan 05-01 primitive | integration | `go test ./plugins/golem15/fonoteka/... -run TestCollectionBeforeDeleteCascadesAlbums` | ❌ W0 | ⬜ pending |
| 05-02-T2 | 05-02 | 2 | DATA-03 | — | Artist/Genre/Style `BeforeValidate`+`AfterDelete` hooks ported verbatim from PHP (slug/name_key defaulting, unassign-not-cascade, pivot detach) | integration | `go test ./plugins/golem15/fonoteka/... -run 'TestArtistBeforeValidate|TestGenreHooks|TestStyleHooks'` | ❌ W0 | ⬜ pending |
| 05-02-T3 | 05-02 | 2 | DATA-03 | T-05-23 | `Album.BeforeSave` (`refreshTrackTitles`+`stampMarketPrice`) fires on save and writes `track_titles`/normalizes money | integration | `go test ./plugins/golem15/fonoteka/... -run TestSaveAlbumMoneyNormalization` | ❌ W0 | ⬜ pending |
| 05-02-T3 | 05-02 | 2 | DATA-04 | — | 3+ artist album round-trips sort_order via explicit sync, never Association mode | integration | `go test ./plugins/golem15/fonoteka/classes/... -run TestAlbumArtistsOrderRoundTrip` | ❌ W0 | ⬜ pending |
| 05-02-T3 | 05-02 | 2 | DATA-04 | — | Collection.Editors RegisterJoinTable round-trips role/granted_at/granted_by on Preload | integration | `go test ./plugins/golem15/fonoteka/... -run TestCollectionEditorsPivotRoundTrip` | ❌ W0 | ⬜ pending |
| 05-02-T3 | 05-02 | 2 | DATA-05 | T-05-06 | Untranslatable rule fails loudly; 422 map Laravel-shaped; unique:table respects soft deletes | unit + integration | `go test ./lagoon/... -run TestValidate` | ❌ W0 | ⬜ pending |
| 05-01-T3 | 05-01 | 1 | DATA-06 | T-05-01 | Unknown/server-owned keys never persisted (framework primitive) | unit | `go test ./lagoon/... -run TestFill` | ❌ W0 | ⬜ pending |
| 05-06-T1 | 05-06 | 5 | DATA-06 | T-05-04, T-05-20 | Fill boundary fuzzed against real Postgres for Album/Collection/4 credential models | integration (fuzz) | `go test ./plugins/golem15/fonoteka/classes/... -fuzz=FuzzSaveAlbum -fuzztime=20s` | ❌ W0 | ⬜ pending |
| 05-02-T2 | 05-02 | 2 | DATA-07 | — | Money never float64; jsonable [] vs null per column (tracklist as `[]TrackEntry`, cover_import_failures as `[]string`) | unit | `go test ./lagoon/... -run TestJsonable` | ❌ W0 | ⬜ pending |
| 05-02-T3 | 05-02 | 2 | DATA-07 | T-05-23 | Money blank/null/non-numeric/PHP-ceiling/normal-value cases round-trip through `SaveAlbum` against real Postgres, never a bare float | integration | `go test ./plugins/golem15/fonoteka/... -run TestSaveAlbumMoneyNormalization` | ❌ W0 | ⬜ pending |
| 05-03-T1 | 05-03 | 2 | DATA-07 | T-05-08, T-05-09, T-05-10 | Ciphertext at rest, redacted marshal, plaintext only via Reveal(); key rotation via previous_keys; HKDF derivation uses stdlib `crypto/hkdf` only | unit + integration | `go test ./lagoon/... -run TestEncrypted` | ❌ W0 | ⬜ pending |
| 05-04-T1 | 05-04 | 3 | DATA-08 | T-05-13 | Thumb filename/partition match Winter exactly; lazy single resize | unit | `go test ./lagoon/attach/... -run 'TestThumbFilename|TestPartitionDirectory'` | ❌ W0 | ⬜ pending |
| 05-04-T2 | 05-04 | 3 | DATA-08 | T-05-14 | Force delete removes blobs only after commit; soft delete keeps them | integration | `go test ./lagoon/attach/... -run TestFileLifecycle` | ❌ W0 | ⬜ pending |
| 05-05-T2 | 05-05 | 4 | DATA-09 | T-05-17 | Go schema equals committed PHP snapshot modulo a justified allow-list | integration | `go test ./parity/... -run TestSchemaMatchesPHPSnapshot` | ❌ W0 | ⬜ pending |
| 05-02-T1 | 05-02 | 2 | DATA-09 | — | Album/Collection slice migrations run up/down individually (incl. `track_titles`) | integration | `go test ./parity/... -run TestAlbumSliceMigrationsUpDown` | ❌ W0 | ⬜ pending |
| 05-01-T4 | 05-01 | 1 | DATA-10 | — | Envelope is {data,meta{...}}, no links key | unit | `go test ./lagoon/... -run TestPaginate` | ❌ W0 | ⬜ pending |
| 05-05-T3 | 05-05 | 4 | DATA-11 | T-05-19 | Test-only fixture plugin Boot() callback + own companion migration extend Album without editing models/album.go or fonoteka updates.All() | integration | `go test ./parity/... -run TestCrossPluginCallback` | ❌ W0 | ⬜ pending |
| 05-05-T3 | 05-05 | 4 | CLI-03 | — | Rollback touches only the named plugin's history, against the full schema | integration | `go test ./parity/... -run TestRollbackIsolatesFonotekaFullSchema` | ❌ W0 | ⬜ pending |
| 05-06-T2 | 05-06 | 5 | DATA-06/DATA-08 | T-05-21 | Every registered model's Hidden() columns are unmarshalable | unit | `go test ./lagoon/... -run TestHiddenNeverMarshals` | ❌ W0 | ⬜ pending |
| 05-06-T2 | 05-06 | 5 | DATA-09 | T-05-07, T-05-18 | Collection.public_token delete-then-recreate matches PHP's plain-unique behavior | integration | `go test ./plugins/golem15/fonoteka/classes/... -run TestCollectionPublicTokenDeleteThenRecreate` | ❌ W0 | ⬜ pending |
| 05-06-T3 | 05-06 | 5 | DATA-08 | T-05-15, T-05-16 | Full attachment lifecycle (photos, image, thumb, soft/force delete) against memblob | integration | `go test ./plugins/golem15/fonoteka/classes/... -run TestAttachSmoke` | ❌ W0 | ⬜ pending |
| 05-06-T3 | 05-06 | 5 | ALL (review) | T-05-01..T-05-24 | Every threat across all 5 prior plans mapped to a passing test or restated acceptance | manual + grep-verified | `grep -rn "AutoMigrate" fonoteka.go/plugins summercms.go/lagoon` (expect 0) | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -57,26 +70,27 @@ Task IDs are filled in by the planner; the requirement → test mapping below is
## Wave 0 Requirements
- [ ] `fonoteka.go/parity/testdata/php_schema_snapshot.sql` — committed D-02 golden snapshot of the PHP final schema
- [ ] `lagoon/fill_test.go`, `lagoon/validate_test.go`, `lagoon/encrypted_test.go`, `lagoon/paginate_test.go` — tests for the new `lagoon` primitives
- [ ] `lagoon/attach/` package (File model, blob wiring, `Thumb()`) — does not exist yet
- [ ] Existing test infra (`lagoon/postgres_test.go` TestMain, `fonoteka.go/parity` TestMain) is reused, not rebuilt
- [ ] `fonoteka.go/parity/testdata/php_schema_snapshot.sql` — committed D-02 golden snapshot of the PHP final schema (Plan 05-05, Task 2)
- [ ] `lagoon/fill_test.go`, `lagoon/lifecycle_test.go`, `lagoon/paginate_test.go`, `lagoon/relations_test.go` (Plan 05-01), `lagoon/jsonable_test.go`, `lagoon/validate_test.go` (Plan 05-02), `lagoon/encrypted_test.go`, `lagoon/keygen_test.go`, `lagoon/laravel_decrypt_test.go` (Plan 05-03) — tests for the new `lagoon` primitives
- [ ] `lagoon/attach/` package (File model, blob wiring, `Thumb()`) — does not exist yet, created by Plan 05-04
- [ ] Existing test infra (`lagoon/postgres_test.go` TestMain, `fonoteka.go/parity` TestMain, `activateAppPlugins`/`parityDB`/`gormOnSharedPool` helpers) is reused, not rebuilt, across all 6 plans
- [ ] `fonoteka.go/parity/fixtureplugin/` — test-only plugin (`plugin.go` + `migrations.go`) activated only in `TestCrossPluginCallback`; not in `app/app.go`, `plugins.gen.go`, or fonoteka `updates.All()`
---
## Manual-Only Verifications
All phase behaviors have automated verification.
All phase behaviors have automated verification. The one review-shaped item — `05-SECURITY-REVIEW.md` (Plan 05-06, Task 3) — is grep-verified (zero `AutoMigrate`, every `.Reveal()` call site enumerated and checked against logging/serialization paths) rather than purely narrative.
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 120s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify (every task across all 6 plans carries its own `<verify><automated>`)
- [x] Wave 0 covers all MISSING references (schema snapshot, new lagoon test files, lagoon/attach package)
- [x] No watch-mode flags
- [x] Feedback latency < 120s (fuzz targets are explicitly time-boxed via `-fuzztime`, outside the 120s per-task feedback loop)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** approved at plan time (2026-09-18) — 6 plans written per the confirmed plan-count checkpoint; task IDs above are real, sourced from `05-01-PLAN.md` through `05-06-PLAN.md`. Revised 2026-09-18 after checker feedback: 05-01's Task 1 split into a probe/gate task and a restructure task (renumbering its lagoon-primitive tasks to T3/T4); DATA-03/DATA-07 rows added for the real hook implementations and money-normalization round-trip landed in 05-02; T-05-23/T-05-24 threat IDs added. Revision 2 (2026-09-18): DATA-11 fixture plugin is test-only (not in fonoteka `updates.All()`); Discogs IDs are TEXT/`*string`; Collection.Editors + StaticHandler action rows added. Task statuses remain ⬜ pending — this revision does not re-approve Nyquist.