--- 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. Create `.planning/phases/05-data-layer-full-fidelity/05-05-SUMMARY.md` when done