Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-PATTERNS.md
2026-09-18 18:06:55 +02:00

485 lines
34 KiB
Markdown

# Phase 5: Data layer full fidelity - Pattern Map
**Mapped:** 2026-09-18
**Files analyzed:** ~50 distinct file/file-group targets (25 models grouped by shape, framework primitives, migrations, tests)
**Analogs found:** 44 / 50 (6 net-new primitives have no in-repo analog and fall back to RESEARCH.md code examples)
Two repos are in scope: `summercms.go` (framework: `lagoon`, `pact`, `backpack`, `compass`, `bouncer`) and `../fonoteka.go` (app: `plugins/golem15/fonoteka`, `plugins/golem15/user`, `parity`). All paths below are relative to one of these two roots; the root is named in the "Repo" column.
## File Classification
| New/Modified File (or group) | Repo | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
| `plugins/golem15/fonoteka/models/*.go` (simple CRUD models: AlbumRating, AlbumReservation, Notification, WishlistSubscription, WishlistDigestQueue, UserCollectionContext, PendingInvitationRegistration, etc.) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/genre.go`, `active_collection.go` (Collection/CollectionEditor/UserCollectionContext structs) | exact |
| `plugins/golem15/fonoteka/models/album.go` (dense model: casts, jsonable, hooks, relations) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/genre.go` (Genre+Album stub) — shape only, none of the dense features exist yet | role-match |
| `plugins/golem15/fonoteka/models/artist.go`, `style.go` (belongsToMany + slug hook) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/genre.go` (beforeValidate-equivalent slug intent is new; struct shape matches Genre) | role-match |
| `plugins/golem15/fonoteka/models/album_artist.go`, `collection_editor.go` (pivot models with business columns) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/active_collection.go` `CollectionEditor` struct (currently pivot-only, no business columns yet) | role-match |
| `plugins/golem15/fonoteka/models/user_ai_credential.go`, `org_ai_credential.go`, `user_discogs_credential.go`, `org_discogs_credential.go` (encrypted cast) | fonoteka.go | model | CRUD | none in-repo; struct shape from `active_collection.go`, encrypted field from RESEARCH.md D-10..D-13 code example | no analog (see below) |
| `plugins/golem15/fonoteka/models/oauth_client.go`, `oauth_auth_code.go`, `oauth_refresh_token.go`, `api_token.go` (hash columns, jsonable) | fonoteka.go | model | CRUD | `plugins/golem15/fonoteka/genre.go` struct shape; hash-hidden pattern from `password.go` (hashed column handling) | role-match |
| `plugins/golem15/user/models/user.go` (widened), `organisation.go` (new) | fonoteka.go | model | CRUD | `plugins/golem15/user/user.go` `User` struct | exact |
| `plugins/golem15/fonoteka/classes/artist_resolver.go` (Album's `beforeSave` service-calling hook) | fonoteka.go | service | event-driven | `plugins/golem15/fonoteka/active_collection.go` (`ResolveActiveCollection`, transactional service function outside models/) | role-match |
| `plugins/golem15/fonoteka/classes/album_write_service.go`, `collection_write_service.go`, credential write services (fill boundary, D-05/D-07) | fonoteka.go | service | CRUD | `plugins/golem15/fonoteka/active_collection.go` (transactional service function pattern) + `plugins/golem15/fonoteka/genre_handler.go` (query/response shaping) | role-match |
| `plugins/golem15/fonoteka/classes/casts/money_string.go` (or `lagoon` if promoted) | fonoteka.go or summercms.go | utility | transform | none in-repo; PHP `MarketPriceCast.php` + RESEARCH.md "Money cast" code example | no analog |
| `plugins/golem15/fonoteka/updates/*.go` (widen_collections, widen_albums, create_artists, create_styles, ... 15 new migrations) | fonoteka.go | migration | batch | `plugins/golem15/fonoteka/migrations.go` (current `202609170001_create_schema` / `202609170002_seed_genres`) | exact |
| `plugins/golem15/user/updates/*.go` (widen_users, create_organisations) | fonoteka.go | migration | batch | `plugins/golem15/user/migrations.go` (`202609170001_create_users`) | exact |
| `lagoon/attach/migrations.go` (`create_system_files`, framework-owned, runs before every plugin set) | summercms.go | migration | batch | `plugins/golem15/fonoteka/migrations.go` (squashed-migration-with-comment style); ordering concern documented in RESEARCH.md D-14 | role-match |
| `plugins/golem15/fonoteka/plugin.go` (extend `Models()`/`Migrations()` to the full 25 + register `classes/`-based hooks) | fonoteka.go | config/provider | request-response | `plugins/golem15/fonoteka/plugin.go` (current) | exact |
| `plugins/golem15/user/plugin.go` (extend `Models()`/`Migrations()`) | fonoteka.go | config/provider | request-response | `plugins/golem15/user/plugin.go` (current) | exact |
| `lagoon/fill.go` (D-05/D-06: `lagoon.Fill` allow-list copy) | summercms.go | utility | transform | `lagoon/order.go` (`OrderBy`/`orderClause` — same allow-list-then-apply shape) | role-match |
| `lagoon/validate.go` (or `lifeguard/`) (D-09: rule-string → validator.Var() + Laravel-shaped 422 map) | summercms.go | utility | request-response | `phrasebook/translator.go` (`Translator.Get`/`find` — same "look up, translate, fall back" shape) + `bouncer/jwt.go` (error-mapping-to-message shape in `mapJWTError`) | role-match |
| `lagoon/encrypted.go` (D-10..D-13: `lagoon.Encrypted` Scanner/Valuer, AES-256-GCM, HKDF key derivation) | summercms.go | utility | transform | `bouncer/jwt.go` (`Verify`/secret-from-config handling) for the "read a required secret from config, fail loudly" shape; `lagoon/connection.go` `CheckLocale`-style fail-boot pattern | role-match (no crypto analog exists) |
| `lagoon/paginate.go` (DATA-10: `{data, meta{...}}` envelope) | summercms.go | utility | transform | `plugins/golem15/fonoteka/genre_handler.go` (`GenreList{Data: rows}` envelope shape, `writeJSON` helper) | role-match |
| `lagoon/attach/file.go` (D-14/D-16/D-17: framework `File` model, blob wiring, `Thumb()`) | summercms.go | model + service | file-I/O | none in-repo (first blob-storage code in the repo); RESEARCH.md "Winter File thumb filename" and `disintegration/imaging` code examples are the source | no analog |
| `lagoon/callbacks.go` (D-11: GORM callback registry helper, if promoted out of ad-hoc `db.Callback()` calls) | summercms.go | utility | event-driven | none in-repo; RESEARCH.md "Cross-plugin lifecycle extension" code example (verified against GORM's own `Callback()` API) | no analog |
| `cmd/summer` key-generate command (D-11: `summer key:generate`) | summercms.go | config | request-response | `lagoon/commands.go` (`RuntimeCommands` — `migrate`/`migrate:rollback`/`migrate:status` command trio) | exact |
| `fonoteka.go/parity/schema_diff_test.go` (D-02: Go migrations vs PHP snapshot diff) | fonoteka.go | test | batch | `fonoteka.go/parity/migrate_test.go` (testcontainers-backed, `parityDB`/`gormOnSharedPool`/`activateAppPlugins` helpers) | exact |
| `lagoon/fill_test.go`, `validate_test.go`, `encrypted_test.go`, `paginate_test.go` | summercms.go | test | — | `lagoon/order_test.go`, `lagoon/connection_test.go` (plain `testing.T`, table-driven, no testcontainers needed for pure functions) | exact |
| `lagoon/attach/file_test.go`, `thumb_test.go` (testcontainers + `memblob`) | summercms.go | test | — | `lagoon/postgres_test.go` (`TestMain` testcontainers pattern, `testShort()` skip convention) | exact |
| `plugins/golem15/fonoteka/classes/album_write_service_fuzz_test.go` (D-07: fill-boundary fuzz against real Postgres) | fonoteka.go | test | — | `fonoteka.go/parity/migrate_test.go` (`activateAppPlugins`, shared-pool pattern) | role-match |
## Pattern Assignments
### `plugins/golem15/fonoteka/models/*.go` — simple CRUD models (group)
**Analog:** `fonoteka.go/plugins/golem15/fonoteka/genre.go` (whole file, 47 lines) and `active_collection.go` lines 10-38
**Struct + TableName pattern** (genre.go lines 1-11):
```go
package fonoteka
// Genre is the GORM model for golem15_fonoteka_genres.
type Genre struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"`
Description *string `gorm:"column:description"`
}
func (Genre) TableName() string { return "golem15_fonoteka_genres" }
```
Every ported model follows this shape: explicit `gorm:"column:..."` tags (never relying on GORM's default snake_case inference, since PHP column names sometimes diverge from Go field naming), nullable columns as pointer types (`*string`, `*uint`), and an explicit `TableName()` method — GORM's pluralization never matches the `golem15_fonoteka_*` prefix convention.
**Multiple related structs in one file** (`active_collection.go` lines 10-38): when models are tightly coupled (Collection + its pivot + its context row), they can share a file — follow this precedent for pivot-model-plus-owner groupings (e.g. `album_artist.go` could hold both `Artist` and `AlbumArtist` if that reads better, matching how `active_collection.go` holds `Collection`, `CollectionEditor`, `UserCollectionContext` together).
**D-05/D-06 addition (net new, no analog):** every model additionally declares `Fillable() []string` and, where PHP has `$hidden`, `Hidden() []string` plus `json:"-"` tags — copy the PHP arrays from the canonical model files verbatim (RESEARCH.md's Full Per-Model Inventory table gives the exact F/H columns per model).
---
### `plugins/golem15/fonoteka/models/album.go` — dense model (casts, jsonable, hooks, relations)
**Analog:** `genre.go`'s `Album` stub (lines 13-21) for the base shape; PHP `Album.php` (canonical ref) for the full field/relation/hook list; RESEARCH.md "Money cast" and "Pattern: GORM many-to-many with pivot business columns" code examples for the parts with no in-repo precedent.
**What carries over from the stub:**
```go
type Album struct {
ID uint `gorm:"column:id;primaryKey"`
CollectionID uint `gorm:"column:collection_id"`
GenreID *uint `gorm:"column:genre_id"`
Name string `gorm:"column:name"`
}
func (Album) TableName() string { return "golem15_fonoteka_albums" }
```
This is the Phase-3 minimal shape; Phase 5 widens it to 28 columns per RESEARCH.md's Full Per-Model Inventory row 1, adds `Fillable()`/`Hidden()`/`Rules()`, the `MoneyString`-typed `MarketPriceStored` field, `Jsonable`-cast `Tracklist`/`CoverImportFailures` fields, and `SetupJoinTable`-based `Artists`/`Styles` relations.
**No in-repo analog for:** the money cast, the jsonable columns, or the `beforeSave` hook calling `classes/artist_resolver.go`. Use RESEARCH.md's verbatim code examples ("Pattern: Money cast (never float64)", D-07 pitfall on validator min/max) as the primary source, cross-checked against `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php` and `models/Album.php` (canonical refs in CONTEXT.md).
---
### `plugins/golem15/fonoteka/models/album_artist.go`, `collection_editor.go` (pivot models with business columns)
**Analog:** `active_collection.go` lines 21-27 (`CollectionEditor`, currently pivot-columns-only) + RESEARCH.md "Pattern: GORM many-to-many with pivot business columns" (verified against GORM's own docs).
**Current pivot shape** (`active_collection.go:21-27`):
```go
type CollectionEditor struct {
ID uint `gorm:"column:id;primaryKey"`
CollectionID uint `gorm:"column:collection_id"`
UserID uint `gorm:"column:user_id"`
}
func (CollectionEditor) TableName() string { return "golem15_fonoteka_collection_editors" }
```
Phase 5 adds `Role`, `GrantedAt`, `GrantedBy` (already in Phase-3's schema per RESEARCH.md's migration-list note — "collection_editors ... needs no widening") and registers it via `db.SetupJoinTable(&Collection{}, "Editors", &CollectionEditor{})`.
**New `AlbumArtist` pivot (no analog, net new):**
```go
// Source: RESEARCH.md verified against gorm.io/docs/many_to_many.html
type AlbumArtist struct {
AlbumID uint `gorm:"column:album_id;primaryKey"`
ArtistID uint `gorm:"column:artist_id;primaryKey"`
SortOrder int `gorm:"column:sort_order;default:0"`
}
func (AlbumArtist) TableName() string { return "golem15_fonoteka_album_artists" }
```
**Critical:** do not use `Association("Artists").Append()`/`Replace()` for writes — GORM's Association Mode has no path to set `sort_order` (RESEARCH.md's confirmed gap). Port `AlbumWriteService::syncArtists()` as an explicit delete-then-bulk-insert function in `classes/album_write_service.go`, in the same transaction as the parent save.
---
### `plugins/golem15/fonoteka/models/user_ai_credential.go` etc. — encrypted-cast models
**Analog:** none in-repo (first encrypted-column model). Struct shape from `active_collection.go`; the `lagoon.Encrypted` field type and `Hidden()` pattern from RESEARCH.md's D-10..D-13 code example (verbatim):
```go
type UserAiCredential struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"column:user_id"`
Provider string `gorm:"column:provider"`
APIKey lagoon.Encrypted `gorm:"column:api_key"` // AES-256-GCM, redacts on String()/MarshalJSON()
Model *string `gorm:"column:model"`
BaseURL *string `gorm:"column:base_url"`
}
func (UserAiCredential) Hidden() []string { return []string{"api_key"} }
```
Column is Postgres `text` (never `varchar`) — confirmed live in RESEARCH.md.
---
### `lagoon/fill.go` (D-05/D-06)
**Analog:** `lagoon/order.go` (whole file, 47 lines) — same "allow-list, then apply" shape as `Fill` needs (`OrderBy`/`orderClause` validate an untrusted column name against an allow-list before building a clause; `Fill` validates untrusted map keys against a fillable allow-list before copying onto a struct).
**Allow-list validation shape to copy** (`lagoon/order.go` lines 25-37):
```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 {
for _, a := range allowed {
if a == column {
return true
}
}
return false
}
```
`lagoon.Fill(model any, allowed []string, requested map[string]any) error` should use the same linear allow-list check per key, reflect-set only allow-listed+requested fields, and — per D-06 — log dropped keys once per call site in non-production (reuse `phrasebook`'s `sync.Map`-based "log once" pattern from `Translator.logMissing`, `phrasebook/translator.go` lines 179-187, rather than inventing a new dedup mechanism).
**Test analog:** `lagoon/order_test.go` (plain `testing.T`, no testcontainers) — `lagoon/fill_test.go` follows the same style since `Fill` is a pure struct-mutation function.
---
### `lagoon/validate.go` (D-09)
**Analog:** `phrasebook/translator.go` `Translator.Get`/`find` (lines 118-136, 162-177) for the "resolve a key, fall back, log once if missing" shape, and `bouncer/jwt.go` `mapJWTError` (lines 115-133) for the "map a library error into a stable, named message" shape.
**Message-lookup-with-fallback shape to copy** (`phrasebook/translator.go:162-177`):
```go
func (t *Translator) find(locale, key string) (entry, string, bool) {
if t == nil {
return entry{}, locale, false
}
fallback := defaultFallback
if t.fallback != "" {
fallback = t.fallback
}
for _, step := range fallbackChain(locale, fallback) {
if e, ok := t.cat.lookup(step, key); ok {
return e, step, true
}
}
t.logMissing(key)
return entry{}, locale, false
}
```
`lagoon.Validate(model, rules map[string]string, values map[string]any, tx *gorm.DB) map[string][]string` should: (1) translate each PHP rule string to a `go-playground/validator` tag string once (fail loudly — boot-time or test-time panic — on an untranslatable rule, per D-09), (2) run `validate.Var()` per field, (3) run `unique:table` as a direct DB query scoped to exclude `deleted_at IS NOT NULL` rows when the table has that column, (4) translate the resulting field errors through `phrasebook.Translator.Get` into the Laravel-shaped `{"field": ["message"]}` map used by `genre_handler.go`'s existing 422 response shape (see below).
**Existing 422 response shape to match** (`plugins/golem15/fonoteka/genre_handler.go:43-47`):
```go
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Validation failed",
"errors": map[string][]string{"non_empty": {polishValidationIn}},
})
```
This is the exact envelope `lagoon.Validate`'s output must slot into (though the HTTP write itself is out of scope this phase — Phase 5 stops at producing the `map[string][]string`).
**Rule-string → tag translation table:** use RESEARCH.md's verified inventory verbatim (`between:X,Y` → `min=X,max=Y`; `oneof` needs single-quoting for the `EP 7"` format value; Money's `numeric|min:0|max:999999.9999` needs a custom validation func since `min`/`max` dispatch on Go kind, not on a string-backed type's parsed value — see RESEARCH.md "Pitfall: go-playground/validator's min/max tags don't parse a custom string-backed Money type").
---
### `lagoon/encrypted.go` (D-10..D-13)
**Analog:** none in-repo for the crypto itself. `bouncer/jwt.go` `Verify` (lines 67-84) is the closest shape for "read a required secret, fail with a named error if empty/invalid" — reuse that discipline:
```go
func Verify(tokenString, secret string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", fmt.Errorf("bouncer: jwt secret is empty")
}
...
}
```
`lagoon.Encrypted`'s key-loading path should read `app.key` from `compass.Config` the same way `plugins/golem15/user/user.go`'s `jwtSecret` function reads `golem15.user.jwt.secret` (lines 47-56) — trim, check empty, return a named, greppable error (`"lagoon: app.key is empty (set SUMMER_APP__KEY)"`) that fails boot with no default, matching P3 D-11's precedent (`fonoteka.go/parity/migrate_test.go`'s `TestEmptyJWTSecretFailsBoot`, lines 238-255, is the direct test-pattern analog for `TestEmptyAppKeyFailsBoot`).
**Fail-boot-loudly precedent** (`lagoon/connection.go` `checkLocale`, lines 129-136): the shape of "construct a maximally actionable error message naming the exact fix" — `lagoon.Encrypted`'s key-derivation errors (missing/short/undecodable `app.key`) should follow the same verbosity, not a bare `errors.New`.
**Crypto implementation itself:** no analog in this codebase (first AES-GCM code). Source directly from RESEARCH.md's D-10..D-13 decisions: HKDF-derive the column key from `app.key` with a fixed label, versioned ciphertext (format/key-id prefix + nonce + ciphertext+tag), `app.previous_keys` as decrypt-only fallback list, `MarshalJSON`/`String()`/`GoString()` always redact, plaintext only via explicit `.Reveal()`.
---
### `lagoon/paginate.go` (DATA-10)
**Analog:** `plugins/golem15/fonoteka/genre_handler.go` lines 14-25 (`GenreAggregate`/`GenreList` envelope) for the "dedicated response struct, not the GORM model" discipline, and `writeJSON` (lines 134-145) for the encode-without-trailing-newline convention.
**Envelope shape to generalize:**
```go
// GenreList is the PHP {"data":[...]} envelope.
type GenreList struct {
Data []GenreAggregate `json:"data"`
}
```
`lagoon.Paginate[T any](rows []T, page, perPage int, total int64) Page[T]` should produce the fixed `{data, meta{current_page,last_page,per_page,total}}` shape (no `links`, per D-10/Claude's Discretion) as a generic wrapper around this same `{Data: ...}` idiom — keep the `json:"data"` tag convention and the "never marshal the GORM model directly" rule genre_handler.go already establishes (it queries into a dedicated `GenreAggregate` struct via `.Scan()`, never `.Find(&Genre{})` for API output).
---
### `lagoon/attach/file.go` (D-14, D-16, D-17)
**Analog:** none in-repo (first blob-storage / attachment code). Use RESEARCH.md's verified code examples directly:
**Thumb filename + partition rule** (RESEARCH.md "Code Examples" section, verified against `vendor/winter/storm/src/Database/Attach/File.php:634-646` and `:1046-1049`):
```go
// Thumb filename: thumb_<id>_<width>_<height>_<offsetX>_<offsetY>_<mode>.<ext>
// Partition directory: first 9 chars of disk_name, split into 3 groups of 3, joined by '/'
```
Implement `Thumb(w, h, mode int/string) string` as pure string formatting (no imaging import needed for the naming itself).
**Resize call shape** (RESEARCH.md, `disintegration/imaging` v1.6.2):
```go
func makeThumb(src image.Image, w, h int, mode string) *image.NRGBA {
switch mode {
case "crop":
return imaging.Fill(src, w, h, imaging.Center, imaging.Lanczos)
case "exact":
return imaging.Resize(src, w, h, imaging.Lanczos)
default:
return imaging.Fit(src, w, h, imaging.Lanczos)
}
}
```
**Model shape:** follow the same explicit `gorm:"column:..."` + `TableName()` discipline as `genre.go`; `system_files` columns are given verbatim in RESEARCH.md's "system_files verified column set" (note: `attachment_id` is `VARCHAR(255)`, not an integer FK — Winter's morph convention).
**Blob wiring:** `gocloud.dev/blob` + `fileblob` (prod/dev) / `memblob` (tests) is a new dependency with no in-repo precedent; `lagoon/connection.go`'s `Open`/`Use`/`Publish`/`OpenFromApp` quartet (lines 26-111) is the pattern to mirror for a parallel `attach.OpenBucket`/`attach.Publish` pair that stores the bucket on `backpack.App` via `app.Publish` (see `backpack/services.go` below), following the same "one shared handle, published once" discipline already used for `*sql.DB`/`*gorm.DB`.
**Test analog:** `lagoon/postgres_test.go` `TestMain` (testcontainers-postgres, `testShort()` skip) is the pattern for `lagoon/attach`'s own tests — use `memblob` in `-short` mode, real Postgres via testcontainers for full-suite attachment lifecycle tests.
---
### `lagoon/callbacks.go` / cross-plugin lifecycle hooks (D-11)
**Analog:** none in-repo yet (first use of `db.Callback()`). RESEARCH.md's "Pattern: Cross-plugin lifecycle extension without editing the owning model" is the verified source (cross-checked against real GORM `Callback()` API):
```go
db.Callback().Create().After("gorm:create").Register("fonoteka:notify_album_created", func(tx *gorm.DB) {
if tx.Statement.Schema == nil || tx.Statement.Schema.ModelType != reflect.TypeOf(Album{}) {
return
}
// side effect, registered once at boot
})
```
Registration site: a plugin's `Boot(app *backpack.App)` method — follow `plugins/golem15/fonoteka/plugin.go`'s existing `Boot` (lines 32-35, currently a no-op) as the wiring point; the callback is registered against the shared `*gorm.DB` looked up via `app.Lookup[*gorm.DB]()`, same lookup idiom `active_collection.go`/`genre_handler.go` and `user.go`'s `FindByID` already use (`app.Lookup[*gorm.DB]()`, e.g. `user.go:32`).
---
### `plugins/golem15/fonoteka/classes/artist_resolver.go`, `album_write_service.go` etc. (service-calling hooks, fill boundary)
**Analog:** `plugins/golem15/fonoteka/active_collection.go` (whole file) — the established shape for a `classes/`-equivalent service function outside `models/`: takes `context.Context` + `*gorm.DB`, wraps multi-step logic in `gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ... })`, returns a typed result or error, never touches HTTP concerns.
**Transaction shape to copy** (`active_collection.go` lines 63-101):
```go
func ResolveActiveCollection(ctx context.Context, gdb *gorm.DB, userID uint) (*Collection, error) {
if gdb == nil {
return nil, errors.New("fonoteka: gorm handle is missing")
}
var out *Collection
err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
...
})
if err != nil {
return nil, err
}
return out, nil
}
```
`AlbumWriteService`'s fill-then-save function and `Collection.beforeDelete`'s cascade-soft-delete function (DATA-03) both follow this same "nil-check the handle, wrap in `Transaction`, return typed result" shape. The `FILL_FIELDS` narrower allow-list (D-05) is a plain `var` slice at the top of the service file, exactly like `active_collection.go`'s `kindRealCollection` constant and `AccessibleByMembership`'s scope-building style — small, named, greppable, diffable against the PHP source line by line.
**Once-per-plugin leaf-layout note (folded todo):** `classes/artist_resolver.go` is exactly the "service-calling hook that must live outside `models/`" case the folded todo (`verify-models-leaf-rule.md`) exists to confirm — `Album`'s `beforeSave` in `models/album.go` calls into `classes/artist_resolver.go`, which `internal/build/leaf.go`'s `inspectModelsImports` (lines 47-81) will reject if the import direction is reversed (models/ importing classes/ is fine only in the sense that the **hook registration** happens from `classes/`, not that `models/` imports `classes/` — confirm the exact edge direction against the probe results before laying out 25 models this way).
---
### `plugins/golem15/fonoteka/updates/*.go`, `plugins/golem15/user/updates/*.go` (D-01, D-03, D-04)
**Analog:** `plugins/golem15/fonoteka/migrations.go` (whole file, 129 lines) and `plugins/golem15/user/migrations.go` (whole file, 30 lines).
**Migration struct + Migrate/Rollback shape** (`fonoteka/migrations.go` lines 22-99):
```go
var migrations = []*gormigrate.Migration{
{
ID: "202609170001_create_schema",
Migrate: func(tx *gorm.DB) error {
stmts := []string{ /* CREATE TABLE ... */ }
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
stmts := []string{ /* DROP TABLE IF EXISTS ... */ }
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
},
...
}
```
Every new migration (widen_collections, widen_albums, create_artists, ... the 15 remaining rows in RESEARCH.md's "Squashed Migration List") follows this exact `ID`/`Migrate`/`Rollback` shape, raw `tx.Exec` SQL strings (never `AutoMigrate`), and a doc-comment above the `var` naming which PHP `updates/v*/*.php` files it folds — copy the doc-comment discipline from `fonoteka/migrations.go` lines 8-21 verbatim (the comment block naming folded PHP files sits directly above the `var migrations` declaration).
**Idempotent data-migration shape** (`fonoteka/migrations.go` lines 100-127, `202609170002_seed_genres`): the various-artist taxonomy seed (D-04) follows this same "check existence by natural key, insert if absent, delete-by-key on rollback" pattern:
```go
Migrate: func(tx *gorm.DB) error {
for _, g := range CanonicalGenres {
var n int64
if err := tx.Table("golem15_fonoteka_genres").Where("slug = ?", g.Slug).Count(&n).Error; err != nil {
return err
}
if n > 0 {
continue
}
if err := tx.Exec(`INSERT INTO ...`, g.Name, g.Slug).Error; err != nil {
return err
}
}
return nil
},
```
**Framework-owned `system_files` migration** (D-14): same shape, but lives in `lagoon/attach/migrations.go` and must be wired to run before every plugin's set — no existing precedent for a framework-level (non-plugin) migration set; `lagoon/migrations.go`'s `Migrate(gdb, plugins)` function (lines 59-82) iterates `plugins []party.Plugin`, so the cleanest fit is likely a small synthetic "framework" entry or a dedicated `lagoon.MigrateAttachments(gdb)` called before `lagoon.Migrate` in `app/app.go`'s `Handler` (mirroring how `lagoon.Publish` already runs before `party.Activate` there, `fonoteka.go/app/app.go` lines 32-39) — this is a design decision for the planner, not fully precedented.
---
### `fonoteka.go/parity/schema_diff_test.go` (D-02)
**Analog:** `fonoteka.go/parity/migrate_test.go` (whole file, 306 lines) — `parityDB`/`gormOnSharedPool`/`activateAppPlugins`/`dsnWithDB` helpers (defined in this file and its sibling `parity_test.go`) are the exact harness to reuse.
**Dedicated-database-per-test shape** (`migrate_test.go` lines 95-121, `TestRollbackLastIsolatesFonoteka`):
```go
admin := parityDB(t)
ctx := t.Context()
if _, err := admin.ExecContext(ctx, `CREATE DATABASE rollback_iso TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`); err != nil && !strings.Contains(err.Error(), "already exists") {
t.Fatalf("create rollback_iso: %v", err)
}
isoDSN, err := dsnWithDB(parityDSN, "rollback_iso")
...
t.Cleanup(func() {
_ = isoDB.Close()
_, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS rollback_iso WITH (FORCE)`)
})
```
`TestSchemaMatchesPHPSnapshot` follows the same "spin up a dedicated ICU pl-PL database, migrate the Go set against it, tear down in `t.Cleanup`" shape, then diffs `information_schema`/`pg_catalog` against the committed `fonoteka.go/parity/testdata/php_schema_snapshot.sql` (generated once per RESEARCH.md's reproducible method — this file itself does not yet exist, is a Wave-0 gap).
**AutoMigrate guard precedent** (`migrate_test.go` lines 206-227, `TestPluginMigrationsDoNotUseAutoMigrate`): a static-source-scan test that greps every plugin `.go` file for the literal string `AutoMigrate` — extend this same scan (or add a sibling test) to cover the new `updates/` and `lagoon/attach/` trees once they exist, since Pitfall 13 applies there too.
---
## Shared Patterns
### GORM model struct shape (applies to all 25 new models)
**Source:** `fonoteka.go/plugins/golem15/fonoteka/genre.go` lines 1-11, `active_collection.go` lines 10-38
**Apply to:** every file under `plugins/golem15/fonoteka/models/` and `plugins/golem15/user/models/`
```go
type X struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
// nullable columns as pointers:
Description *string `gorm:"column:description"`
}
func (X) TableName() string { return "golem15_fonoteka_x" }
```
Never rely on GORM's default table-name pluralization or default column-name snake-casing — always explicit `gorm:"column:..."` and an explicit `TableName()`, matching every existing model in the repo.
### Allow-list-before-apply
**Source:** `summercms.go/lagoon/order.go` lines 25-37
**Apply to:** `lagoon.Fill` (D-05/D-06), `lagoon.Validate`'s `unique:table` column resolution, any place an untrusted key/column name reaches SQL or reflection.
```go
func allowListed(column string, allowed []string) bool {
for _, a := range allowed {
if a == column {
return true
}
}
return false
}
```
### Fail boot loudly on misconfiguration
**Source:** `summercms.go/bouncer/jwt.go` `Verify` lines 67-70; `summercms.go/lagoon/connection.go` `checkLocale` lines 129-136; `fonoteka.go/plugins/golem15/user/user.go` `jwtSecret` lines 47-56
**Apply to:** `lagoon.Encrypted`'s `app.key` loading (D-11), `lagoon.Validate`'s untranslatable-rule check (D-09), `lagoon/attach`'s unconfigured-bucket check (D-16).
```go
func jwtSecret(app *backpack.App) (string, error) {
if app == nil || app.Config == nil {
return "", fmt.Errorf("golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)")
}
secret := strings.TrimSpace(app.Config.String("golem15.user.jwt.secret"))
if secret == "" {
return "", fmt.Errorf("golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)")
}
return secret, nil
}
```
Every new secret/key-shaped config value follows this exact "nil-check app+config, trim, empty-check, named actionable error mentioning the `SUMMER_` env var" shape.
### Response DTO, never the GORM model
**Source:** `fonoteka.go/plugins/golem15/fonoteka/genre_handler.go` lines 14-25
**Apply to:** any `Serialize*` function ported this phase (D-08) and `lagoon.Paginate`'s row type.
```go
type GenreAggregate struct {
ID int64 `json:"id" gorm:"column:id"`
...
}
type GenreList struct {
Data []GenreAggregate `json:"data"`
}
```
### Shared *sql.DB / *gorm.DB publish-once discipline
**Source:** `summercms.go/lagoon/connection.go` `Open`/`Use`/`Publish`/`OpenFromApp` lines 26-111; `summercms.go/backpack/services.go` `Registry.Publish`/`Lookup` (generic, duplicate-publish rejected)
**Apply to:** `lagoon/attach`'s blob bucket handle — publish once via `app.Publish(bucket)`, look up via `app.Lookup[*blob.Bucket]()`, never open a second bucket/connection per call site.
### Migration file shape (gormigrate)
**Source:** `fonoteka.go/plugins/golem15/fonoteka/migrations.go` (whole file), `fonoteka.go/plugins/golem15/user/migrations.go` (whole file)
**Apply to:** every file under `updates/` in both plugins and `lagoon/attach/migrations.go`.
```go
var migrations = []*gormigrate.Migration{
{
ID: "YYYYMMDDNNNN_description",
Migrate: func(tx *gorm.DB) error { /* raw tx.Exec SQL, never AutoMigrate */ return nil },
Rollback: func(tx *gorm.DB) error { /* real down migration */ return nil },
},
}
```
Doc-comment above `var migrations` names every PHP `updates/v*/*.php` file the Go migration folds (D-01) — copy the comment-block discipline from `fonoteka/migrations.go` lines 8-21 exactly.
## No Analog Found
Files/primitives with no close match anywhere in either repo — planner should lean on RESEARCH.md's verbatim code examples (cross-checked against real library docs/PHP source) rather than an in-repo precedent:
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `lagoon/encrypted.go` (AES-256-GCM + HKDF core) | utility | transform | First crypto code in the repo; RESEARCH.md D-10..D-13 code examples and Laravel's own encryption docs are the only source |
| `lagoon/attach/file.go` (blob wiring, `gocloud.dev/blob`) | service | file-I/O | First blob-storage code in the repo; RESEARCH.md's Winter `File.php`-derived thumb/partition rules and `disintegration/imaging` examples are the only source |
| `plugins/golem15/fonoteka/classes/casts/money_string.go` | utility | transform | First custom Scanner/Valuer cast in the repo; PHP `MarketPriceCast.php` (canonical ref) + RESEARCH.md "Pattern: Money cast" are the only source |
| `lagoon/callbacks.go` (GORM callback registry wrapper) | utility | event-driven | First use of `db.Callback()` in the repo; RESEARCH.md's verified-against-GORM-docs code example is the only source |
| `lagoon/validate.go` rule-string→tag translation table | utility | transform | No existing Laravel-rule-grammar translator in the repo; RESEARCH.md's verified inventory (6 models, `between`/`oneof`/money pitfalls) is the only source |
| `lagoon/attach/migrations.go` cross-cutting-before-every-plugin-set ordering | migration | batch | No existing "framework migration set that must run before plugin sets" precedent; `lagoon/migrations.go`'s `Migrate(gdb, plugins)` only knows about `party.Plugin`-shaped sets today — planner must decide the exact wiring point in `app/app.go`'s `Handler` |
## Metadata
**Analog search scope:** `summercms.go` (`lagoon/`, `pact/`, `backpack/`, `compass/`, `bouncer/`, `phrasebook/`, `party/`, `bonfire/`, `internal/build/`) and `../fonoteka.go` (`plugins/golem15/fonoteka/`, `plugins/golem15/user/`, `parity/`, `app/`, `config/`)
**Files scanned:** ~35 read in full (every non-test `.go` file in both repos' current plugin/lagoon/pact/backpack/compass/bouncer/phrasebook/party trees) plus 5 existing test files for harness patterns
**Pattern extraction date:** 2026-09-18