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