34 KiB
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):
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:
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):
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):
// 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):
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):
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):
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):
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:
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:
// 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):
// 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):
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):
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):
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):
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:
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):
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/
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.
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).
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.
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.
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