docs(05): create phase plan

This commit is contained in:
Jakub Zych
2026-09-18 18:06:55 +02:00
parent bbf49a910a
commit 29b73b6892
6 changed files with 504 additions and 16 deletions

View File

@@ -194,10 +194,10 @@ Plans:
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 05-02-PLAN.md — Album/Collection slice: migrations, models, casts, validation engine, write services
- [ ] 05-03-PLAN.md — Secrets slice: encrypted cast, key management, organisations, credentials/OAuth tables
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 05-03-PLAN.md — Secrets slice: encrypted cast, key management, organisations, credentials/OAuth tables
- [ ] 05-04-PLAN.md — Attachments: system_files, blob storage, thumbnails, delete lifecycle
**Wave 4** *(blocked on Wave 3 completion)*

View File

@@ -2,14 +2,14 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
status: executing
stopped_at: Phase 5 context gathered
last_updated: "2026-09-18T13:23:21.558Z"
last_activity: 2026-09-18
last_updated: "2026-09-18T16:06:30.536Z"
last_activity: 2026-09-18 -- Phase 05 planning complete
progress:
total_phases: 15
completed_phases: 4
total_plans: 17
total_plans: 23
completed_plans: 17
percent: 27
---
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 5
Plan: Not started
Status: Ready to plan
Last activity: 2026-09-18
Status: Ready to execute
Last activity: 2026-09-18 -- Phase 05 planning complete
Progress: [██████████] 100%

View File

@@ -2,8 +2,8 @@
phase: 05-data-layer-full-fidelity
plan: 03
type: execute
wave: 2
depends_on: ["05-01"]
wave: 3
depends_on: ["05-01", "05-02"]
files_modified:
- summercms.go/lagoon/encrypted.go
- summercms.go/lagoon/encrypted_test.go
@@ -203,7 +203,7 @@ func (c *Config) Lookup(path string) (any, bool)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 4, 13, 14, 15, 16, 17, 21, 23; "Pattern: Encrypted-at-rest cast" code example)
</read_first>
<action>
Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs following `updates/10_album_slice.go`'s date/sequence convention (independent numbering from Plan 05-02's file — both are new files in the same wave, no shared line edited):
Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs continuing Plan 05-02's `updates/10_album_slice.go` date/sequence (Wave 2 already shipped that file; this plan is Wave 3, no shared line edited):
1. `create_api_tokens` — `golem15_fonoteka_api_tokens (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, token_hash TEXT NOT NULL UNIQUE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, last_used_ip TEXT, oauth_client_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify exact types against the two folded files; `scopes`/`collection_ids` are `text` jsonable columns per RESEARCH.md row 4, not native `jsonb`).
2. `create_oauth_tables` — `golem15_fonoteka_oauth_clients (id SERIAL PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash TEXT NOT NULL, client_name TEXT, redirect_uris TEXT, grant_types TEXT, token_endpoint_auth_method TEXT, registration_ip TEXT, consented_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, scope_ceiling TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_auth_codes (id SERIAL PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, code_hash TEXT NOT NULL UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, redirect_uri TEXT, scopes TEXT, collection_ids TEXT, code_challenge TEXT, code_challenge_method TEXT, resource TEXT, state TEXT, expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_refresh_tokens (id SERIAL PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, api_token_id INTEGER REFERENCES golem15_fonoteka_api_tokens(id) ON DELETE CASCADE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, rotated_to_id INTEGER, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify against both folded files including `scope_ceiling`).
3. `create_credentials_tables` — `golem15_fonoteka_user_ai_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, base_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_ai_credentials` (same shape, `organisation_id INTEGER NOT NULL UNIQUE REFERENCES golem15_user_organisations(id) ON DELETE CASCADE` in place of `user_id`), `golem15_fonoteka_user_discogs_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_discogs_credentials` (same shape with `organisation_id`) — `api_key`/`token` are `text` columns (never `varchar`, confirmed live per RESEARCH.md). This migration depends on `create_organisations` (Task 2) for the FK target; since gormigrate runs each plugin's set independently in `party.Activate` dependency order and `golem15.fonoteka` `Requires()` `golem15.user`, this ordering is already guaranteed — do not add an explicit cross-plugin migration dependency mechanism.

View File

@@ -145,7 +145,10 @@ func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error)
<action>
Reproduce the exact method RESEARCH.md documents as already verified this research session: run a scratch `postgres:16-alpine` container, run the real PHP app's `php artisan winter:up` against it (from the sibling PHP repo at `/media/nvme/dev/golem15/fonoteka`), then `pg_dump --schema-only` filtered to `golem15_fonoteka_*`, `system_files`, `users`, `golem15_user_organisations` tables, normalizing owner/privilege noise (`--no-owner --no-privileges`). Commit the resulting SQL as `fonoteka.go/parity/testdata/php_schema_snapshot.sql` — this is a one-time, offline generation step; CI/tests never need PHP installed.
Create `parity/schema_diff_test.go`'s `TestSchemaMatchesPHPSnapshot`: spin up a dedicated ICU pl-PL database (same pattern as `migrate_test.go`'s `TestRollbackLastIsolatesFonoteka`), run `lagoon.Migrate(gdb, plugins)` for both `golem15.user` and `golem15.fonoteka` plus the framework's `system_files` set, then load the committed snapshot into a second dedicated database (`psql < php_schema_snapshot.sql` via `exec.Command` or by parsing the SQL directly), and diff both databases' `information_schema.columns`/`information_schema.table_constraints`/`pg_indexes` for the shared table set, failing on any unexplained difference (missing table, missing column, type mismatch, missing/extra unique or FK constraint). Keep a small, explicitly commented allow-list `var allowedDiffs = map[string]string{...}` for genuinely intended differences (e.g. `golem15_fonoteka_settings` existing in the Go schema with no PHP equivalent, since Settings uses a dedicated table per the resolved open question rather than Winter's generic `system_settings` — document exactly why each allow-listed entry exists, per the scope_reduction/gap-handling discipline: this is a deliberate decision, not a silently-tolerated gap). Run `lagoon.Migrate` against the production plugins only (`golem15.user` + `golem15.fonoteka` + framework `system_files`). Do not activate the DATA-11 fixture plugin in this test. Do not add `demo_extension_note` (or any fixture-only column) to `allowedDiffs` — that column must not exist on the production schema (D-02).
Create `parity/schema_diff_test.go`'s `TestSchemaMatchesPHPSnapshot`: spin up a dedicated ICU pl-PL database (same pattern as `migrate_test.go`'s `TestRollbackLastIsolatesFonoteka`), run `lagoon.Migrate(gdb, plugins)` for both `golem15.user` and `golem15.fonoteka` plus the framework's `system_files` set, then load the committed snapshot into a second dedicated database (`psql < php_schema_snapshot.sql` via `exec.Command` or by parsing the SQL directly), and diff both databases' `information_schema.columns`/`information_schema.table_constraints`/`pg_indexes` for the shared table set, failing on any unexplained difference (missing table, missing column, type mismatch, missing/extra unique or FK constraint). Keep a small, explicitly commented allow-list `var allowedDiffs = map[string]string{...}` for genuinely intended differences. Required entries (each with an inline comment naming the decision):
1. `golem15_fonoteka_settings` existing in the Go schema with no PHP equivalent, since Settings uses a dedicated table per the resolved open question rather than Winter's generic `system_settings`.
2. `users` table column-count gap: the PHP snapshot dumps the full 52-column Winter `users` table; Go keeps the Phase-3 stub (no `organisation_id`/`organisation_role`/membership columns). This is intended. Cite the user-confirmed no-`widen_users` decision (D-03 deviation, deferred to Phase 7 AUTH-02). Do NOT add a `widen_users` migration to close this diff — that would contradict the confirmed decision.
Document exactly why each allow-listed entry exists, per the scope_reduction/gap-handling discipline: this is a deliberate decision, not a silently-tolerated gap. Run `lagoon.Migrate` against the production plugins only (`golem15.user` + `golem15.fonoteka` + framework `system_files`). Do not activate the DATA-11 fixture plugin in this test. Do not add `demo_extension_note` (or any fixture-only column) to `allowedDiffs` — that column must not exist on the production schema (D-02).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./parity/... && go test ./parity/... -run TestSchemaMatchesPHPSnapshot</automated>
@@ -153,7 +156,7 @@ func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error)
<acceptance_criteria>
- `fonoteka.go/parity/testdata/php_schema_snapshot.sql` is committed and non-empty, covering all 25 Fonoteka tables plus `system_files`/`users`/`golem15_user_organisations`.
- `TestSchemaMatchesPHPSnapshot` fails if a column is deliberately dropped from a migration in a scratch branch (spot-check this manually during development, then restore before finishing).
- Every entry in `allowedDiffs` has an inline comment naming the specific decision (D-01/D-02/RESEARCH Open Question 2) that justifies it.
- Every entry in `allowedDiffs` has an inline comment naming the specific decision that justifies it: `golem15_fonoteka_settings` cites RESEARCH Open Question 2; the `users` table column-count gap cites the user-confirmed no-`widen_users` decision (D-03 deviation, deferred to Phase 7 AUTH-02). Do not close the `users` gap with a `widen_users` migration.
- `grep -n "demo_extension_note" fonoteka.go/parity/schema_diff_test.go fonoteka.go/plugins/golem15/fonoteka/updates` returns no matches (production schema and its allow-list never mention the fixture column).
</acceptance_criteria>
<done>The committed PHP schema snapshot exists and `TestSchemaMatchesPHPSnapshot` proves the full Go schema matches it modulo a small, justified, commented allow-list.</done>

View File

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

View File

@@ -126,6 +126,7 @@ From the repo-root and `summercms.go` `CLAUDE.md` files (both apply; the `summer
| github.com/go-gormigrate/gormigrate/v2 | v2.1.7 | Migrations | Already decided and already in use since Phase 3 (`go.mod` line 7) — `[VERIFIED: go.sum]`. **Note:** `slopcheck` flagged this package `[SLOP]` in this session ("created 2 days ago... no source repository linked"); this is a false positive from the sandboxed Go module proxy's "first-seen" timestamp, not the package's actual age — the package has been a real, working, committed dependency in this repo since Phase 3/4 with matching go.sum checksums across both `summercms.go` and `fonoteka.go`. See Package Legitimacy Audit below for the full explanation; it is NOT removed. |
| github.com/go-playground/validator/v10 | v10.30.4 (released 2026-09-03) | Rule-string validation | Already decided (STACK.md), confirmed current via `proxy.golang.org` — `[VERIFIED: Go module proxy]` |
| gocloud.dev/blob (+ fileblob, memblob) | v0.46.0 (released 2026-06-02) | Attachment storage abstraction | Already decided (STACK.md, D-15), confirmed current via `proxy.golang.org` — `[VERIFIED: Go module proxy]` |
| crypto/hkdf (stdlib) | Go 1.27 standard library | HKDF key derivation for `lagoon.Encrypted`'s column key (D-11) | Go 1.24+ ships HKDF in the standard library (`hkdf.Key`/`Extract`/`Expand`) — no `golang.org/x/crypto` dependency needed; this phase's Plan 05-03 uses stdlib directly, correcting an earlier draft that named `golang.org/x/crypto/hkdf` |
### Supporting
| Library | Version | Purpose | When to Use |
@@ -503,19 +504,19 @@ func makeThumb(src image.Image, w, h int, mode string) *image.NRGBA {
**If this table is empty:** N/A — see above; all four assumptions are low-risk implementation-convention calls, not unverified factual claims about the PHP source or the target Go libraries (which were all verified live in this session).
## Open Questions
## Open Questions (RESOLVED)
1. **Does `widen_users` need `organisation_id`/`organisation_role` now, or does Phase 7 add them?**
1. **RESOLVED (user decision, 2026-09-18): `widen_users` is deferred to Phase 7 — no `widen_users` migration ships in Phase 5.** (Original question: does `widen_users` need `organisation_id`/`organisation_role` now, or does Phase 7 add them?)
- What we know: no Fonoteka model in this phase reads them; the columns exist in the real PHP `users` table and are cheap/additive.
- What's unclear: whether the planner wants to front-load this to avoid a second `users` ALTER in Phase 7, or keep Phase 5 strictly scoped to what this phase's own success criteria need.
- Recommendation: default to NOT adding them in Phase 5 (keeps the phase's `widen_users` migration honestly empty/minimal, matching "plus the golem15.user tables they depend on" in the phase boundary text); let Phase 7 (AUTH-02, Organisations with roles) own its own widen migration, since that's where the columns' actual behavior lands.
2. **Where does the `Settings` model's storage live, given it has no dedicated migration in the 38 PHP files (it rides Winter's generic `system_settings` singleton-row-per-code mechanism)?**
2. **RESOLVED (user decision, 2026-09-18): dedicated typed table `golem15_fonoteka_settings`, not Winter's generic `system_settings` mechanism.** (Original question: where does the `Settings` model's storage live, given it has no dedicated migration in the 38 PHP files?)
- What we know: only one field, `search_use_typesense` (boolean), is used; Winter's `SettingsModel` behavior serializes the whole settings array into one `system_settings.value` text column keyed by `item = 'golem15_fonoteka_settings'`.
- What's unclear: whether to port Winter's generic serialized-value mechanism (more PHP-parity-faithful but adds a PHP-`serialize()`-format decoder nobody else needs) or give `Settings` its own tiny dedicated table with a typed `search_use_typesense BOOLEAN` column (simpler, matches this project's "matching final schema" spirit at the field level even though the storage *mechanism* differs from PHP).
- Recommendation: dedicated table (`golem15_fonoteka_settings`, singleton row, typed boolean column) — Phase 9's ADMIN-05 needs a settings screen bound through "the same schema pipeline" regardless of storage shape, and a typed column is strictly easier for both this phase and Phase 9 than porting Winter's generic key-value behavior for a single flag. This is a recommendation, not a locked decision — confirm with the user/planner since it's a deliberate departure from PHP's storage mechanism (not from PHP's *data*, which is preserved).
3. **Exact FK columns to declare for `notifications` and `wishlist_subscriptions`/`wishlist_digest_queue`'s `user_id`/`collection_id`** — PHP deliberately omits FKs on these (confirmed: no `->foreign()` calls in the live schema for `golem15_fonoteka_notifications`, `golem15_fonoteka_wishlist_subscriptions`, `golem15_fonoteka_wishlist_digest_queue`), per the migration comments ("this plugin's convention... is to avoid cross-table FKs that already cascade-delete via a different owning relation"). Recommendation: match PHP exactly — no FK constraints on these three tables' `user_id`/`collection_id` columns, even though every other table does declare them. This is already effectively decided by D-02's "match PHP's actual constraints" framing; flagged here only so the planner doesn't "fix" this as an oversight.
3. **RESOLVED: no FK constraints on `notifications`/`wishlist_subscriptions`/`wishlist_digest_queue`'s `user_id`/`collection_id` — matches PHP exactly, not fixed as an oversight.** (Original question: exact FK columns to declare for `notifications` and `wishlist_subscriptions`/`wishlist_digest_queue`'s `user_id`/`collection_id`.) PHP deliberately omits FKs on these (confirmed: no `->foreign()` calls in the live schema for `golem15_fonoteka_notifications`, `golem15_fonoteka_wishlist_subscriptions`, `golem15_fonoteka_wishlist_digest_queue`), per the migration comments ("this plugin's convention... is to avoid cross-table FKs that already cascade-delete via a different owning relation"). Recommendation: match PHP exactly — no FK constraints on these three tables' `user_id`/`collection_id` columns, even though every other table does declare them. This is already effectively decided by D-02's "match PHP's actual constraints" framing; flagged here only so the planner doesn't "fix" this as an oversight.
## Environment Availability