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

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