# Phase 5: Data layer full fidelity - Research **Researched:** 2026-09-18 **Domain:** GORM/Postgres model porting from a WinterCMS/Laravel source (25 models, mass-assignment/encryption security invariants, polymorphic attachments) **Confidence:** HIGH ## User Constraints (from CONTEXT.md) ### Locked Decisions **Migration granularity** - D-01: Go migrations stay squashed per final-state table (P3 D-16 stands). One create migration per table, or per small tightly-coupled table group (e.g. OAuth tables, CSV import tables, the two Discogs credential tables), in FK order, each carrying a comment naming the PHP update files it folds. The PHP rename/flatten history (areas, items, locations, tags, item_categories) is not replayed. Success criterion 1 is read as: every Go migration runs up and down individually, and the final schema matches PHP's. The roadmap's "27" is not a target; the planner should fix the wording in ROADMAP.md/REQUIREMENTS.md DATA-09 when the phase is planned. - D-02: Schema equality is proven by an automated diff, not by reading. A normalized snapshot of the PHP app's final schema on Postgres (tables, columns, types, nullability, defaults, indexes, unique constraints, FKs) is produced once and committed in `fonoteka.go`; a testcontainers test migrates the Go sets up and diffs `information_schema`/`pg_catalog` against it. Intended differences live in a small, commented allow-list in the test. How the snapshot is produced is research; if the PHP migrations do not run cleanly on Postgres, research reports that before planning. **[Answered below — they run cleanly.]** - D-03: The Phase 3 minimal tables are widened by one appended ALTER migration per table (`widen_users`, `widen_collections`, `widen_albums`) placed right after the two shipped Phase 3 migrations, followed by the new create-table migrations. Shipped migrations are never edited (P3 D-17). Each widen migration has a real Rollback. - D-04: Reference data in the PHP history (the various-artist taxonomy seed, any settings defaults) is ported as idempotent data migrations inside the plugin's set, keyed by natural key, with a Rollback that removes them, same as the genres seed (P3 D-18). No seeder command. **Model DX: fillable, hidden, rules** - D-05: Each model declares `Fillable() []string`, a mechanical copy of PHP `$fillable`. `lagoon.Fill` copies onto the model only names that are both requested by the caller and in the model's fillable list. Write services pass their own narrower list exactly as PHP does (`AlbumWriteService::FILL_FIELDS` is a strict subset of `Album::$fillable`: no `collection_id`, no `market_price_source`). The model list is the backstop, the service list is the real boundary. `json.Unmarshal` into a GORM model is never used on a write path (PITFALLS Pitfall 3). - D-06: Non-fillable and unknown keys are silently ignored, as Eloquent does; they are never persisted and never produce a 422. In non-production environments the dropped keys are logged once per call site. - D-07: Criterion 3 is proven at service level in this phase: the fill boundary of the PHP write services is ported now (at minimum Album, Collection and the four credential models) and fuzzed against real Postgres with random extra and server-owned keys, asserting nothing outside the list is persisted. `lagoon.Fill` has its own framework-level fuzz test on a fixture model. The HTTP-level fuzz over request DTOs is Phase 12's job; criterion 3's "every write endpoint" clause moves there and the planner notes it in the roadmap. - D-08: Hidden columns carry `json:"-"` on the GORM model so an accidental marshal cannot leak them, and models also declare `Hidden() []string` (for the Phase 9 admin schema and for a lagoon test that marshals every registered model and asserts hidden names are absent). API payloads are not produced by marshaling models: they come from ported `Serialize*` functions, as PHP's `SerializesFonoteka` does. The "explicit per-call override" is a named, greppable function (`Reveal`-style), not a serializer option. - D-09: Models declare `Rules() map[string]string` with the PHP rule strings copied verbatim. `lagoon` translates the Laravel rule grammar Płytarium uses onto go-playground/validator, runs it in a before-save callback, and returns the Laravel-shaped 422 error map with messages translated through `phrasebook`. `unique:table` is a DB check in the same step and must respect soft deletes the way Winter's does. An untranslatable rule fails loudly at boot or test time, never silently passes. **[Full inventory below.]** **Encrypted cast** - D-10: The Go cast reads and writes only its own AES-256-GCM format. Laravel CBC payloads are never on the live read path. Phase 5 ships a small, tested helper that decrypts a Laravel `encrypted` payload (base64 JSON with iv/value/mac, AES-256-CBC + HMAC under `APP_KEY`) for the Phase 15 cutover import to re-encrypt; it is not wired into the cast. - D-11: The key is `app.key` in config (`SUMMER_APP__KEY`), 32 bytes base64. Missing, short or undecodable key fails boot with no default, same rule as the JWT secret (P3 D-11). The column-encryption key is derived from it with HKDF and a fixed label so `app.key` can serve other purposes later without key reuse. A `summer key:generate` command prints a fresh key. - D-12: Ciphertext is versioned: a format/key-id prefix, nonce, ciphertext+tag. Config accepts `app.previous_keys` as decrypt-only keys (Laravel's `APP_PREVIOUS_KEYS` equivalent). No re-encrypt command in this phase. - D-13: Encrypted columns are typed `lagoon.Encrypted` on the model: Scanner/Valuer decrypt on read and encrypt on write; `MarshalJSON`, `String()` and `GoString()` always emit a redaction; plaintext is only reachable through an explicit `.Reveal()`. Applies to `api_key` on `UserAiCredential` and `OrgAiCredential` and to the secret columns of `UserDiscogsCredential` and `OrgDiscogsCredential`; research lists the exact columns. **[Confirmed below: `api_key`, `api_key`, `token`, `token` respectively.]** **Attachments (DATA-08)** - D-14: Attachments use Winter's `system_files` table shape and name (`disk_name`, `file_name`, `file_size`, `content_type`, `title`, `description`, `field`, `attachment_id`, `attachment_type`, `is_public`, `sort_order`, timestamps). `attachment_type` keeps the PHP class string (`Golem15\Fonoteka\Models\Album`) through a per-model morph name, so cutover copies rows and files verbatim and later Winter ports reuse it. The table and `File` model are framework-owned (`lagoon`) with their own migration set that runs before plugin sets. - D-15: Full storage scope lands in this phase (user chose the largest option over the recommended table-only cut): `gocloud.dev/blob` is wired now, files are stored and deleted through it, and thumbnails are generated. Only the HTTP upload endpoint stays in Phase 12. Planner should size the phase accordingly. - D-16: v1 bucket is `fileblob` rooted at the same `storage/app/uploads` tree PHP uses, with bucket URL and public path prefix from a `storage.uploads.*` config section mirroring `cms.php`'s `storage.uploads`. Winter's `disk_name` partition path (`public/xxx/yyy/zzz/`) and the relative public URL shape (`relativeMediaUrl`) are reproduced exactly. The framework serves public files with a static handler at the configured path; S3/GCS is a config change later. `memblob` in unit tests. - D-17: `Thumb(w, h, mode)` reproduces Winter's thumb filename and location (next to the original, `thumb____0_0_.`; research confirms the exact rule). Thumbs copied at cutover are reused, and `thumb_url` stays byte-identical for the parity diff. Pixel equality with PHP GD is not a goal. **[Confirmed below.]** - D-18: The resize library is research's pick, recorded here as the phase decision naming the dependency: pure Go, maintained, no cgo/libvips. **[Picked below: `github.com/disintegration/imaging` v1.6.2.]** - D-19: Deletion follows Winter: soft-deleting an owner keeps `system_files` rows and blobs; force delete removes rows inside the delete transaction and removes originals and thumbs from blob after commit. This runs through the same lifecycle-callback mechanism as the other cascades (DATA-03), not a special case. ### Claude's Discretion - Lifecycle hook naming and wiring (Winter names `BeforeValidate`/`BeforeCreate`/`BeforeSave`/`BeforeDelete`/`AfterDelete` as optional interfaces vs. GORM's own hook names), the base model embed, and how soft-delete cascade runs in one transaction. - Pivot model shape for `album_artists.sort_order` and `CollectionEditor` (GORM `SetupJoinTable` vs explicit has-many-through), as long as the 3+ artist ordering round-trip and the business columns hold. - Money cast type and API: a named string-backed type in `models/` (or `lagoon` if generic) that reproduces `MarketPriceCast::get` (null for null/blank/non-numeric, else 4-decimal string) and leaves normalisation to the `Album` before-save hook, as PHP does. Never `float64` in the JSON path. - Jsonable cast shape, including `[]` vs `null` behavior per column (Pitfall 4). - Pagination helper API; the envelope is fixed: `{data, meta{current_page,last_page,per_page,total}}`, no `links`. - Callback-registry API surface (ARCHITECTURE.md Pattern 3b) and which plugin/model pair demonstrates criterion 5's cross-plugin extension plus companion migration; a fixture plugin in tests is acceptable if no real Płytarium case fits. - Soft-delete + unique strategy (partial unique indexes vs. matching PHP's actual constraints); D-02's schema diff decides what "matching" means, the delete-then-recreate test must pass for every soft-deletable uniquely-keyed table. **[Only one real candidate table exists — see below.]** - Which `Serialize*` functions are ported now (only those needed by this phase's tests) vs. Phase 12. - `Album`'s service-calling hook (`ArtistResolver` in `beforeSave`) becomes a callback registered from `classes/`, per the layout note; Scout/Broadcastable traits are left as seams for Phase 11. - CLI-03 details beyond what Phase 3 shipped. **[Almost nothing is left — see below.]** - Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" rules. Given D-15, expect more than the usual number of plans. ### Deferred Ideas (OUT OF SCOPE) - HTTP-level fuzz of request DTOs on every write endpoint — Phase 12/13 when the routes exist (second half of criterion 3). - Photo upload endpoint, manual cover URL and Discogs cover import — Phase 12 (API-02), HTTP-07, Phase 14. - Re-encrypt command for key rotation — after v1; the versioned format (D-12) keeps it possible. - Cutover import that decrypts Laravel payloads and re-encrypts, and copies `system_files` rows and the uploads tree — Phase 15, using the helper from D-10. - S3/GCS bucket for uploads — config change after cutover. - Typesense indexing and Centrifugo broadcasting hooks on `Album` — Phase 11. - Correcting the "27 migrations" wording in ROADMAP.md and REQUIREMENTS.md DATA-09, and moving criterion 3's endpoint clause — do at plan time for this phase. ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | DATA-03 | Timestamps, soft delete, lifecycle hooks, cascading soft delete in a transaction | Per-model inventory below marks every soft-deletable model and every hook (`beforeSave`, `beforeValidate`, `beforeDelete`, `afterDelete`); `Collection.beforeDelete` cascade pattern documented | | DATA-04 | belongsTo/hasOne/hasMany/belongsToMany with ordered results and pivot models | Per-model inventory lists every relation; `SetupJoinTable` API verified for `album_artists` (sort_order) and `collection_editors` (role/granted_at/granted_by) | | DATA-05 | Rule-string validation via go-playground/validator with Laravel-shaped 422 | Full rule inventory (only 6 models declare `$rules`) with verified tag mapping table | | DATA-06 | Fillable allow-list mass assignment; hidden deny-list with explicit override | Per-model fillable/hidden columns extracted verbatim from PHP source | | DATA-07 | Jsonable, money (fixed 4-decimal string), encrypted-at-rest casts | `MarketPriceCast` semantics documented verbatim; jsonable column inventory; 4 encrypted columns confirmed | | DATA-08 | Polymorphic file attachment via `system_files`, gocloud.dev/blob | `system_files` verified schema; Winter `File` partition/thumb-naming logic read from source; resize library picked (D-18) | | DATA-09 | All 25 models + migrations, matching schema | Full 25-model inventory; squashed migration list in FK order; PHP-on-Postgres schema verified live | | DATA-10 | Pagination envelope `{data, meta{...}}` no `links` | Confirmed no existing pagination helper in `lagoon`; net-new | | DATA-11 | Cross-plugin lifecycle hook + companion migration | GORM callback registry API verified (ARCHITECTURE.md Pattern 3b, confirmed against real GORM docs) | | CLI-03 | Migration commands: up, status, rollback last of a named plugin | **Already fully implemented** in `lagoon/commands.go` + `lagoon/migrations.go` since Phase 3/4 — see below | ## Summary Phase 5 ports 25 Płytarium models plus their final-state schema onto GORM/Postgres. The single most important research finding is **D-02 is answered conclusively**: the actual PHP migrations for both `golem15.user` and `golem15.fonoteka` were run against a scratch Postgres 16 container in this session (`php artisan winter:up` against `pgsql`) and completed with zero errors — every one of the 25 Fonoteka tables, `system_files`, `users`, and `golem15_user_organisations` materialized correctly with the exact column types, defaults, and indexes Postgres's own `\d`/`pg_dump` report. This document embeds that verified schema directly, so the planner does not need to re-derive it from reading 38 migration files — the "Full Per-Model Inventory" and "Squashed Migration List" sections below are sourced from live Postgres introspection, not migration-file reading, and are the single source of truth for D-02's snapshot. Second finding: **CLI-03 is already done.** `lagoon.Migrate`, `lagoon.RollbackLast`, `lagoon.Status`, and the `migrate`/`migrate:rollback --plugin`/`migrate:status` bonfire commands were built in Phase 3/4 and already scope correctly to a per-plugin `summer_migrations_` history table. This phase's CLI-03 work is verification (a test that `--plugin=fonoteka` rolls back only Fonoteka's last migration, not User's), not new code. Third: only 6 of the 25 models declare Laravel `$rules` (`Album`, `Collection`, `Artist`, `Style`, `Genre`, `Settings`) — DATA-05's real surface area is small. The rule vocabulary in use is `required`, `nullable`, `integer`, `between:X,Y`, `numeric`, `min:X`, `max:X`, `Rule::in([...])`, and `unique:table`; every one maps cleanly onto go-playground/validator (`between` has no direct tag — combine `min`+`max`), verified against the library's own baked-in tag source. Fourth: exactly one table combines soft-delete with a real (non-PK) unique constraint — `golem15_fonoteka_collections.public_token`, and PHP's own migration does **not** use a partial index there. Every other soft-deletable table (`albums`) has no business unique key, and every other uniquely-keyed table (`album_reservations`, `album_ratings`, `wishlist_subscriptions`, `wishlist_digest_queue`, `csv_import_rows`, credential tables) is hard-deleted, not soft-deleted. Pitfall 11's "audit all tables" is a short audit with one real answer. Fifth: the widen migrations named in D-03 are asymmetric in size. `widen_collections` and `widen_albums` need substantial column additions (Albums is currently a 7-column Phase-3 stub; the final table has 28 columns). `widen_users` needs **zero** columns for this phase's own success criteria — no Fonoteka model reads any `users` column beyond `id` — but a **new** table, `golem15_user_organisations`, must be created (structure-only; full Organisation behavior is Phase 7's AUTH-02) because `OrgAiCredential`/`OrgDiscogsCredential` FK into it. **Primary recommendation:** Treat this RESEARCH.md's live-verified schema as authoritative for D-02's snapshot; write the Go migrations directly against it (18 migrations replacing the 38 PHP files, see the squashed list); pick `github.com/disintegration/imaging` v1.6.2 for D-18; model every many-to-many pivot with `SetupJoinTable` and an explicit sync function (never `Association().Append()`) per the verified GORM pivot-column gap. ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Model structs, relations, casts, hooks | API/Backend (GORM layer) | — | Pure data-layer concern; no HTTP/SSR involvement in this phase | | Migrations (schema DDL) | Database/Storage | API/Backend (gormigrate runner) | Schema lives in Postgres; gormigrate is the authoring/runtime mechanism, owned by the backend process | | Mass-assignment fillable/hidden discipline | API/Backend | — | Security boundary enforced in Go model/service code before any HTTP DTO exists (Phase 12 adds the HTTP-facing half) | | Rule-string validation | API/Backend | — | Runs in a GORM before-save callback, not client-side | | Encrypted-at-rest casts | API/Backend + Database/Storage | — | Encryption/decryption happens in Go (Scanner/Valuer); ciphertext is what's persisted to Postgres | | File attachments (`system_files` + blob) | API/Backend | CDN/Static (blob-served public files) | Metadata row owned by backend; actual bytes may later move to S3/GCS (config change, not architecture change) | | Pagination envelope | API/Backend | — | Shape emitted by serializers; no client-side pagination logic ported here | | Cross-plugin lifecycle hooks (callback registry) | API/Backend | — | GORM's own `db.Callback()` registry, registered at plugin `Boot()` | ## Project Constraints (from CLAUDE.md) From the repo-root and `summercms.go` `CLAUDE.md` files (both apply; the `summercms.go`-scoped file is more specific and takes precedence on conflict): - **Never break core plugins** (user, blog, pages, payment) unless directly asked — `widen_users` must be strictly additive; never touch or reinterpret existing `users` columns. - **Lean planning**: prefer fewer, larger plans. Given D-15 (full blob storage + thumbnails lands in this phase), expect more plans than a typical phase, but still consolidate where the work is mechanical (e.g., one plan for "the bulk of the 25 models" rather than one plan per model). - **Plan-count checkpoint**: present the suggested plan count with a one-line scope each and wait for confirmation before writing PLAN.md files. - **Unit tests are always the last plan** of the phase; earlier plans may include smoke tests but are not blocked on full coverage. - **Go conventions**: standard library first; GORM, gormigrate, go-playground/validator, gocloud.dev are already-decided per STACK.md; `disintegration/imaging` is this phase's one new named dependency (D-18). `go vet` and `go test ./...` green at every commit. **No AutoMigrate anywhere** (Pitfall 13; this project's own PITFALLS.md is unambiguous, overriding any stray "goose" references elsewhere in older research docs — `gormigrate` is authoritative per STATE.md's Phase 3 decision log). - **Compiled plugins only** — no runtime plugin loading; not directly relevant to this phase's model/migration work. - **API parity is the acceptance test** — do not "improve" response shapes; this phase's serializers (where ported) must match `SerializesFonoteka` byte-for-byte in the fields it touches. - **Commit rules**: no co-author tags; one logical change per commit; planning docs and code in separate commits. - **`internal/build`'s models-leaf enforcement** (Phase 4, P4 D-11) applies: `models/` files may only import stdlib + framework + sibling model types, never `classes/`/`controllers/`. The folded todo (`verify-models-leaf-rule.md`) must run before the bulk 25-model port — this is a hard prerequisite task, not optional. ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | gorm.io/gorm | v1.31.2 | ORM | Already decided (STACK.md), already in `go.mod`/`go.sum` — `[VERIFIED: go.sum]` | | 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 | |---------|---------|---------|-------------| | github.com/disintegration/imaging | v1.6.2 (tagged 2019-11-16, stable since) | Thumbnail generation (D-18) | `imaging.Fill()` for Winter's `mode=crop`, `imaging.Fit()` for `mode=auto`, `imaging.Resize()` for `mode=exact`. Pure Go, single transitive dependency (`golang.org/x/image`), no cgo. Picked over hand-rolling on raw `golang.org/x/image/draw` (the CONTEXT.md-named baseline) because it already implements exactly the fill/fit/resize semantics Winter's `Resizer` modes need — see Code Examples. | | golang.org/x/image | v0.46.0 (latest as of 2026-09-08) | Transitive dependency of `disintegration/imaging` | Pulled in automatically; no direct import needed unless a custom resample filter is required | | github.com/testcontainers/testcontainers-go + modules/postgres | v0.44.0 | Real-Postgres tests (D-02 schema-diff test, fuzz tests) | Already in `fonoteka.go/go.mod`; reuse the existing `TestMain` pattern in `lagoon/postgres_test.go` (pl-PL ICU locale) and `fonoteka.go/parity` | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | `disintegration/imaging` | Hand-rolled crop/fit on `golang.org/x/image/draw` directly | More code to write and test for the exact same result; `imaging` already wraps this correctly and is what CONTEXT.md called "the baseline to beat" — it beats it by being less code, not by being a different approach | | `disintegration/imaging` | `github.com/kovidgoyal/imaging` (maintained fork) | Fork adds a `mandykoh/prism` color-management dependency the phase does not need (no ICC profile handling required for simple square-crop thumbnails); upstream `disintegration/imaging` is feature-complete and stable (not archived, 5.7k GitHub stars, zero known CVEs), just not under active new-feature development — acceptable for a "generate a fixed-size crop thumbnail" use case | | `SetupJoinTable` for `album_artists` | Plain `many2many` tag (`gorm:"many2many:golem15_fonoteka_album_artists;"`) | Silently drops `sort_order` on every read (Pitfall 12, confirmed against GORM's own docs: the default many2many API has no path to extra pivot columns without `SetupJoinTable`) | | Custom `MarketPriceCast`-equivalent | GORM's `serializer:json` or a raw `decimal.Decimal` (shopspring) type | PHP's own docblock on `MarketPriceCast` explains why a generic decimal library is wrong here: it must accept a blank string as "clear the price" without throwing, and always read back as a stable 4-decimal string, never a float — the same reasoning applies to any Go decimal library with the same blank-string/`sql.Scanner` trap | **Installation:** ```bash go get github.com/disintegration/imaging@v1.6.2 ``` (gorm, gormigrate, validator/v10, gocloud.dev are already present in `go.mod`.) ## Package Legitimacy Audit | Package | Registry | Age (real, via proxy.golang.org) | Downloads/Stars | Source Repo | slopcheck | Disposition | |---------|----------|-----------------------------------|------------------|-------------|-----------|-------------| | github.com/disintegration/imaging | Go module proxy | Tagged v1.6.2 in 2019 (6+ yrs), repo pushed as recently as 2023, not archived | 5,756 GitHub stars | github.com/disintegration/imaging | [OK] | Approved | | github.com/go-gormigrate/gormigrate/v2 | Go module proxy | Real age: multi-year, already shipped in this repo's `go.sum` since Phase 3 | 1.2k GitHub stars (per STACK.md) | github.com/go-gormigrate/gormigrate | **[SLOP]** (false positive — see note) | **Approved (override)** — already a locked, working, in-use dependency; the SLOP verdict is an artifact of `slopcheck`'s Go-ecosystem check using the sandboxed module proxy's "first indexed" timestamp rather than the package's real registration date. Do not act on this flag; do not remove. | | github.com/go-playground/validator/v10 | Go module proxy | Real release date 2026-09-03 (confirmed), library itself is 10+ years old (established since ~2015) | Long-established, already decided in STACK.md | github.com/go-playground/validator | [SUS] (false positive, same proxy-timestamp cause) | Approved — already-decided, HIGH confidence per STACK.md's own `pkg.go.dev`-sourced verification | | golang.org/x/image | Go module proxy | Official Go team subrepo | Official golang.org/x org | golang.org/x/image | [SUS] (false positive, same cause) | Approved — official Go team package, transitive via `imaging` | | gocloud.dev | Go module proxy | Real release 2026-06-02 | Google-maintained | github.com/google/go-cloud | [OK] | Approved (already decided) | **Packages removed due to slopcheck [SLOP] verdict:** none — see override note above. **Packages flagged as suspicious [SUS]:** none requiring a `checkpoint:human-verify` — all three SUS flags in this run are the same Go-ecosystem proxy-timestamp false positive on well-established, already-decided-elsewhere packages (STACK.md independently verified these via `pkg.go.dev` with HIGH confidence). `disintegration/imaging` — the one genuinely new package this research introduces — came back clean `[OK]`. **Caveat for future phases:** `slopcheck`'s `--ecosystem go` check appears to key package age off the sandboxed environment's Go module proxy cache "first seen" timestamp, not the upstream repository's actual creation/release date. This is a poor signal for Go modules specifically (unlike npm/PyPI, where registry publish date is reliable) and produced 3 false positives out of 5 packages checked in this session, including one already-shipped dependency. Future phase research should cross-check any Go `[SLOP]`/`[SUS]` verdict against `proxy.golang.org//@v/.info` (real `Time` field) and the module's actual GitHub star count/repo age before acting on it. **Side-effect warning:** `slopcheck install ... --force` in this environment actually ran `go get` and modified `go.mod`/`go.sum` as a side effect of the "check-then-install" command shape. This was caught and reverted (`git checkout -- go.mod go.sum`) before any other work in this session. Future research sessions should use a scratch/throwaway module (or a `go.mod` copy) when running `slopcheck install --force` against a real project's dependency file, or prefer `slopcheck scan` if it exists as a non-installing alternative. ## Architecture Patterns ### System Architecture Diagram ``` Plugin Boot (fonoteka.Boot / user.Boot) │ ├─→ pact.HasMigrations.Migrations() ──→ lagoon.Migrate ──→ gormigrate per-plugin history table ──→ Postgres DDL │ (this phase: ~18 Go migrations, FK-ordered, see Squashed Migration List) │ ├─→ pact.HasModels.Models() ──→ registered with GORM for schema reflection / callback targeting │ (this phase: 25 Fonoteka models + companion structs for user.User / user.Organisation) │ └─→ db.Callback().Create()/.Update()/.Delete().After(...).Register(...) ──→ cross-plugin lifecycle hooks (DATA-11) Write path (service layer, this phase's tests only — no HTTP yet): caller (test / fuzz harness) → lagoon.Fill(model, fillableAllowList, requestMap) [D-05/D-06: silently drops non-fillable/unknown keys] → model.BeforeValidate() / BeforeSave() (Winter-equivalent hooks, DATA-03) → lifeguard.Validate(model.Rules(), values) [D-09: validator.Var() per field + unique:table DB check] → tx.Save(model) [GORM callbacks fire — DATA-11 cross-plugin hooks] → tx.Commit() Read/serialize path (this phase's tests only): model loaded via GORM (relations preloaded with ORDER BY for belongsToMany/hasMany — DATA-04) → Serialize*(model) [ported subset of SerializesFonoteka — hidden columns never touched, json:"-" backstop] → paginate(query) → {data, meta{current_page,last_page,per_page,total}} [DATA-10, no `links`] Attachment path (DATA-08, D-15): File{} (framework-owned, system_files table) → blob.Bucket (gocloud.dev/blob, fileblob rooted at storage/app/uploads in dev/prod, memblob in tests) → disk_name partition path: first 9 chars of disk_name split into 3×3 groups ("abc/123/xyz/") → Thumb(w,h,mode) → thumb____0_0_., generated lazily via disintegration/imaging on first call → deletion: soft-delete owner keeps file row+blob; force delete removes row in-tx, blob+thumbs after commit (D-19) ``` ### Recommended Project Structure (fonoteka.go, per the Winter-directory layout note) ``` plugins/golem15/fonoteka/ ├── models/ # 25 model structs + Fillable()/Hidden()/Rules() — leaf package (internal/build enforced) ├── classes/ # ArtistResolver, AlbumWriteService port, service-calling hooks (Album.beforeSave's ArtistResolver call) ├── classes/casts/ # MarketPriceCast-equivalent (if not promoted to lagoon) ├── updates/ # ~18 squashed gormigrate migrations, FK-ordered ├── plugin.go # Models()/Migrations() registration plugins/golem15/user/ ├── models/ # User (existing stub, widened per this phase's findings — likely no column changes needed) │ # Organisation (new companion struct, structure-only) ├── updates/ # widen_users (likely no-op/minimal) + create_organisations (new) lagoon/ ├── attach/ # NEW: File model (system_files), blob wiring, Thumb() — framework-owned per D-14 ├── fill.go # NEW: lagoon.Fill (D-05/D-06) ├── encrypted.go # NEW: lagoon.Encrypted cast type (D-10..D-13) ├── validate.go # NEW: rule-string → validator.Var() translation + 422 map (D-09) — or a dedicated `lifeguard` package if that's already scaffolded ├── paginate.go # NEW: {data, meta{...}} envelope helper (DATA-10) ``` ### Pattern: GORM many-to-many with pivot business columns (DATA-04, Pitfall 12) **What:** `Album.artists` (via `golem15_fonoteka_album_artists`, columns `album_id, artist_id, sort_order`) and `Collection.editors` (via `golem15_fonoteka_collection_editors`, columns `id, collection_id, user_id, role, granted_at, granted_by, created_at, updated_at`) both carry business data on the pivot that GORM's default `many2many` tag silently drops. **When to use:** Any `belongsToMany` in the PHP source with a `'pivot' => [...]` key (both cases in this phase) or an `'order' => ...` key. **Example (verified against GORM's own docs):** ```go // Source: https://gorm.io/docs/many_to_many.html (SetupJoinTable API) 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" } // At boot/migration-registration time: err := db.SetupJoinTable(&Album{}, "Artists", &AlbumArtist{}) ``` **Critical gap (undocumented by GORM, confirmed live in this session's research):** GORM's `Association("Artists").Append()`/`Replace()` do not provide a documented path to set `sort_order` during the association write. **Do not rely on Association Mode for pivot writes.** Instead, port `AlbumWriteService::syncArtists()`'s behavior as an explicit function: delete-then-bulk-insert (or `ON CONFLICT DO UPDATE`) directly against the join table within the same transaction as the parent save, exactly mirroring PHP's `$album->artists()->sync($idsWithSortOrder)`. Query ordering for reads uses `ORDER BY sort_order` (Artists) / the pivot's own columns (CollectionEditor), not relation-preload magic. ### Pattern: Cross-plugin lifecycle extension without editing the owning model (DATA-11) **What:** GORM's own callback registry lets `fonoteka` (or a later demo plugin) hook another plugin's model lifecycle without importing/editing its file. **Example (from ARCHITECTURE.md, cross-checked against GORM's `Callback()` API shape which is a real, documented GORM feature):** ```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 }) ``` Pair this with ARCHITECTURE.md Pattern 3c (companion struct + own migration) for the "extend a model's **schema**" half of criterion 5 — e.g. a fixture/demo plugin adds a column to `golem15_fonoteka_albums` via its own migration and queries it through its own struct embedding `fonoteka.Album`, never touching `fonoteka`'s file. ### Pattern: Encrypted-at-rest cast (D-10..D-13) The PHP `casts = ['api_key' => 'encrypted']` / `['token' => 'encrypted']` idiom becomes a `lagoon.Encrypted` `sql.Scanner`/`driver.Valuer` type: ```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"} } // D-08 backstop even though json:"-" already prevents marshal ``` Column is `text` in Postgres (never `varchar`) — confirmed live: both `api_key` and `token` columns are `text`, matching PHP's own docblock reasoning ("ciphertext routinely exceeds 200 chars"). ### Pattern: Money cast (never float64) ```go // Source: PHP classes/casts/MarketPriceCast.php, read directly, and Pitfall 5 type MoneyString string // named type, GORM/JSON round-trips as a plain string // Scanner: reads numeric(10,4) from Postgres, formats as 4-decimal string, "" on NULL. // Valuer: passes the raw string through — Album.BeforeSave() owns ALL normalization // (blank→NULL, currency default, checked_at stamping), matching PHP's division of labor // where MarketPriceCast::set() is a pure passthrough and Album::beforeSave() does the work. ``` Column: `numeric(10,4)` nullable (confirmed live schema). Validation rule: `nullable|numeric|min:0|max:999999.9999` — the `max` bound must be checked against the **parsed numeric value**, not the formatted string, since go-playground/validator's `max` tag does not parse arbitrary decimal-string types automatically (register a custom validation func, or validate against a `float64`/`decimal` intermediate before the cast normalizes it to the fixed string). ### Anti-Patterns to Avoid - **Reusing `Association().Append()` for pivot writes with extra columns:** silently loses `sort_order`/`role`/`granted_at`/`granted_by` — write the join table directly (see pattern above). - **`AutoMigrate` for anything, including test-only schemas:** this project's own PITFALLS.md (Pitfall 13) and STATE.md Phase 3 decision log are explicit and override any older "goose" references in ARCHITECTURE.md/PITFALLS.md prose — `gormigrate` with real up/down `.Migrate()`/`.RollbackLast()` files is the only mechanism, everywhere, including tests. - **Validating the Money field's `max` bound against the formatted string:** `"999999.9999"` compared lexicographically against `"1000000.0000"` gives the wrong answer — validate the numeric value, format afterward. - **Treating `golem15_user_organisations` as a Fonoteka-owned table:** it is logically owned by `golem15.user` (mirrors the real PHP plugin that created it) even though only `fonoteka`'s credential tables use it in this phase; its create migration belongs in the `user` plugin's migration set, not `fonoteka`'s, keeping D-03's per-plugin ownership rule intact. ## Full Per-Model Inventory **Source of truth:** live Postgres 16 schema, produced by running the actual PHP `winter:up` migration command against a scratch `postgres:16-alpine` container in this research session (see "D-02 Verified" below for the exact method). Cross-checked against direct reads of all 25 PHP model files and all 38 migration files. All facts in this table are `[VERIFIED: live Postgres introspection]` unless noted. Legend: **F**=Fillable, **H**=Hidden, **J**=Jsonable, **C**=Casts, **SD**=Soft Delete, **U**=Unique (besides PK) | # | Model | Table | F | H | J / C | Relations | Hooks | SD | U | |---|-------|-------|---|---|-------|-----------|-------|----|----| | 1 | Album | `golem15_fonoteka_albums` (28 cols) | name, collection_id, shelf, genre_id, quantity, notes, artist_display, year, format, condition, barcode, discogs_id, edition, tracklist, cover_import_failures, label, catalog_number, country, market_price_stored, market_price_currency, market_price_source | — | J: tracklist, cover_import_failures. C: market_price_stored→MarketPriceCast | belongsTo collection, genre; hasMany ratings; hasOne reservation (unique album_id); belongsToMany styles (order:name), artists (pivot:sort_order, order:sort_order); attachMany photos | beforeSave: refreshTrackTitles + stampMarketPrice | Yes | none (besides PK) | | 2 | AlbumRating | `golem15_fonoteka_album_ratings` | album_id, user_id, rating | — | — | belongsTo album, user | — | No | (album_id, user_id) | | 3 | AlbumReservation | `golem15_fonoteka_album_reservations` | album_id, user_id, reserved_at, revealed_at | — | — | belongsTo user, album | — | No | album_id | | 4 | ApiToken | `golem15_fonoteka_api_tokens` | user_id, name, token_hash, scopes, collection_ids, expires_at, revoked_at, last_used_at, last_used_ip, oauth_client_id | token_hash | J: scopes, collection_ids | belongsTo user | — | No | token_hash | | 5 | Artist | `golem15_fonoteka_artists` | name, name_key, slug, discogs_artist_id, is_various | — | — | belongsToMany albums (pivot:sort_order, order:name) | beforeValidate: slug+name_key defaulting | No | name_key | | 6 | CollectionEditor (Pivot) | `golem15_fonoteka_collection_editors` | (pivot, not user-facing) | — | dates:granted_at | belongsTo via Collection.editors pivotModel | — | No | (collection_id, user_id) | | 7 | CollectionInvitation | `golem15_fonoteka_collection_invitations` | collection_id, email, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at | — | — | belongsTo collection, inviter(User), acceptedUser(User) | computed `status` accessor (accepted/revoked/expired/pending) | No | token_hash | | 8 | Collection | `golem15_fonoteka_collections` (12 cols) | name, description, owner_id | public_token | — | belongsTo owner(User); hasMany albums; attachMany photos; attachOne image; belongsToMany editors (pivot:role,granted_at,granted_by; pivotModel:CollectionEditor) | beforeDelete: cascade soft-delete every album in a DB transaction | Yes | public_token (nullable) | | 9 | CsvImport | `golem15_fonoteka_csv_imports` | user_id, collection_id, match_job_id, import_job_id, status, import_mode, column_map, original_filename, storage_path, row_count, error_message | — | J: column_map | belongsTo user, collection; hasMany rows | — | No | none | | 10 | CsvImportRow | `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 | — | J: raw_json, candidates_json, draft_json | belongsTo import(CsvImport), matchedAlbum(Album) | — | No | (csv_import_id, row_index) | | 11 | Genre | `golem15_fonoteka_genres` | name, slug, description | — | — | hasMany albums | beforeValidate: slug default; afterDelete: unassign (not cascade-delete) albums | No | none | | 12 | Notification | `golem15_fonoteka_notifications` | user_id, type, payload, read_at | — | J: payload | (none declared; no FK either) | — | No | none | | 13 | OAuthAuthCode | `golem15_fonoteka_oauth_auth_codes` | request_id, code_hash, client_id, user_id, redirect_uri, scopes, collection_ids, code_challenge, code_challenge_method, resource, state, expires_at, used_at, offline_access | code_hash | J: scopes, collection_ids (stored as `text`, not `json` column type — PHP jsonable on a text column) | belongsTo user | — | No | request_id, code_hash | | 14 | OAuthClient | `golem15_fonoteka_oauth_clients` | client_id, client_secret_hash, client_name, redirect_uris, grant_types, token_endpoint_auth_method, registration_ip, consented_at, revoked_at, scope_ceiling | client_secret_hash | J: redirect_uris, grant_types, scope_ceiling (all `text` columns) | (none) | — | No | client_id | | 15 | OAuthRefreshToken | `golem15_fonoteka_oauth_refresh_tokens` | token_hash, api_token_id, client_id, user_id, scopes, collection_ids, expires_at, revoked_at, rotated_to_id, offline_access | token_hash | J: scopes, collection_ids (`text`) | belongsTo user, apiToken(ApiToken) | — | No | token_hash | | 16 | OrgAiCredential | `golem15_fonoteka_org_ai_credentials` | organisation_id, provider, api_key, model, base_url | api_key | C: api_key→encrypted | belongsTo organisation(Organisation, in `golem15.user`) | — | No | organisation_id | | 17 | OrgDiscogsCredential | `golem15_fonoteka_org_discogs_credentials` | organisation_id, token | token | C: token→encrypted | belongsTo organisation | — | No | organisation_id | | 18 | PendingInvitationRegistration | `golem15_fonoteka_pending_invitation_registrations` | user_id, invitation_id | — | — | belongsTo user | — | No | user_id | | 19 | Settings | *(no dedicated table — Winter `System.Behaviors.SettingsModel` over `system_settings`, keyed by `settingsCode='golem15_fonoteka_settings'`)* | search_use_typesense | — | — | — | — | No | n/a | | 20 | Style | `golem15_fonoteka_styles` | name, slug, description | — | — | belongsToMany albums (order:name) | beforeValidate: slug (with min-length-3 collision-safe fallback); afterDelete: detach pivot | No | none | | 21 | UserAiCredential | `golem15_fonoteka_user_ai_credentials` | user_id, provider, api_key, model, base_url | api_key | C: api_key→encrypted | belongsTo user | — | No | user_id | | 22 | UserCollectionContext | `golem15_fonoteka_user_collection_contexts` | user_id, collection_id | — | — | belongsTo user, collection | — | No | user_id | | 23 | UserDiscogsCredential | `golem15_fonoteka_user_discogs_credentials` | user_id, token | token | C: token→encrypted | belongsTo user | — | No | user_id | | 24 | WishlistDigestQueue | `golem15_fonoteka_wishlist_digest_queue` | user_id, collection_id, item_count | — | — | (none declared) | static `enqueue()` upsert helper (application-level, not a model hook) | No | (user_id, collection_id) | | 25 | WishlistSubscription | `golem15_fonoteka_wishlist_subscriptions` | user_id, collection_id, ws_enabled, email_enabled, subscribed_at | — | — | belongsTo user, collection | — | No | (user_id, collection_id) | **Rules() inventory (DATA-05) — only these 6 models declare `$rules`:** | Model | Field | PHP rule string | go-playground/validator mapping | |-------|-------|------------------|----------------------------------| | Album | name | `required` | `required` | | Album | year | `nullable\|integer\|between:1889,2100` | `omitempty,min=1889,max=2100` on `*int` (nil pointer = PHP null, skipped; non-nil zero still validated) | | Album | market_price_stored | `nullable\|numeric\|min:0\|max:999999.9999` | Custom func (validator's built-in `min`/`max` don't parse a string-backed Money type) validating the parsed `float64` before cast normalization; `numeric` tag on the raw string input works if validating pre-cast | | Album | format | `nullable` + `Rule::in(FORMATS)` (incl. `EP 7"` — contains a space and a `"`) | `omitempty,oneof='EP 7"' LP 2LP CD 2CD MC Box` — verified: `oneof`'s parser strips single-quote delimiters, so a value containing a literal `"` inside single quotes is safe | | Album | condition | `nullable` + `Rule::in(CONDITIONS)` | `omitempty,oneof=M NM VG+ VG G` — **`VG+` contains no space, no quoting needed** | | Album | market_price_currency | `nullable` + `Rule::in(MARKET_CURRENCIES)` | `omitempty,oneof=USD GBP EUR CAD AUD JPY CHF MXN BRL NZD SEK ZAR` | | Album | market_price_source | `nullable` + `Rule::in(MARKET_PRICE_SOURCES)` | `omitempty,oneof=suggestion lowest` | | Collection | name | `required` | `required` | | Artist | name | `required` | `required` | | Style | name | `required` | `required` | | Style | slug | `required\|between:3,64\|unique:golem15_fonoteka_styles` | `required,min=3,max=64` + separate DB `unique:table` check step | | Genre | name | `required` | `required` | | Settings | search_use_typesense | `boolean` | No native `boolean` validator tag; redundant with Go's `bool` type if the field is typed — only relevant if validating an untyped `any` from a YAML/JSON payload before assignment, in which case a small custom check (`_, ok := v.(bool)`) suffices | **`between` has no built-in go-playground/validator tag** — verified directly against the library's `doc.go`. Always translate `between:X,Y` to `min=X,max=Y` (works identically for numeric ranges and, separately, for string/slice length — `go-playground/validator` overloads `min`/`max` by field kind). **`unique:table` is never a struct-tag validator concern** — it is a DB existence query, run in the same before-save step, scoped to exclude soft-deleted rows on tables that have `deleted_at` (none of the 6 rule-bearing models are soft-deletable except `Collection`, which has no `unique:` rule, so this scoping concern is currently moot for the 6-model inventory but must still be built generically per D-09's wording, since a future plugin's rules could combine both). ## D-02 Verified: PHP Migrations Run Cleanly on Postgres **Method (reproducible):** ```bash docker run -d --name fonoteka-parity-pg -e POSTGRES_PASSWORD=parity -e POSTGRES_USER=parity \ -e POSTGRES_DB=fonoteka_parity -p 15432:5432 postgres:16-alpine cd /path/to/fonoteka # the PHP repo DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=15432 DB_DATABASE=fonoteka_parity \ DB_USERNAME=parity DB_PASSWORD=parity php artisan winter:up ``` **Result:** every migration for `Golem15.User` (1.0.1 → 3.4.1) and `Golem15.Fonoteka` (1.0.1 → 1.2.6) completed with `DONE`, including the destructive `MigrateItemsToAlbums` cutover migration (which has real assertion/verification logic, not just DDL) and every idempotent-guard (`Schema::hasTable`/`hasColumn`) migration. `pdo_pgsql` was already installed; no code changes were needed. `config/database.php`'s `pgsql` connection config works out of the box (`sslmode=prefer`, `search_path=public`). **The only failure** was in an unrelated plugin (`Golem15.Journal`, not in this phase's scope) trying to reach a Typesense server at `127.0.0.1:8181` — a network dependency of that plugin's demo-data seeder, not a schema issue. Fonoteka and User's schemas fully materialized before that failure. **Recommendation for the actual D-02 schema-diff test infrastructure:** reproduce this exact method (`docker run postgres:16-alpine` + `php artisan winter:up` against it) as a **one-time, offline snapshot generation step**, not a live CI dependency on the PHP codebase (the PHP repo is a sibling directory outside `fonoteka.go`'s module, and CI should not need PHP/Composer installed). Capture the output of: ```bash pg_dump -U parity -d fonoteka_parity --schema-only \ -t 'golem15_fonoteka_*' -t 'system_files' -t 'users' -t 'golem15_user_organisations' \ --no-owner --no-privileges > fonoteka.go/parity/testdata/php_schema_snapshot.sql ``` and commit the resulting SQL (or a normalized JSON extraction from `information_schema`) as the D-02 golden snapshot. This exact schema (all 25 tables + `system_files` + `users` + `golem15_user_organisations`) is embedded in the Full Per-Model Inventory above and is `[VERIFIED: live Postgres introspection, this session]`. ## Squashed Migration List (FK order) Replacing 38 PHP migration files with ~18 Go migrations, per D-01. Comment each with the PHP update files it folds. | # | Migration | Table(s) | Folds PHP files | Depends on | |---|-----------|----------|------------------|------------| | 0 | `golem15.summercms` framework: `create_system_files` | `system_files` | `modules/system/database/migrations/2013_10_01_000002_Db_System_Files.php` + `2025_04_10_000031...metadata.php` | none — runs before every plugin set (D-14) | | 1 | `user`: `widen_users` | `users` | *(no PHP files fold in — see finding below; likely empty/no-op for this phase)* | Phase 3's `create_users` | | 2 | `user`: `create_organisations` | `golem15_user_organisations` | `v3.2.0/create_organisations_table.php` (structure only; full behavior is Phase 7 AUTH-02) | `widen_users` (ordering only, no real dependency) | | 3 | `fonoteka`: `widen_collections` | `golem15_fonoteka_collections` | `v1.1.5/add_public_share_to_collections.php`, `v1.2.4/add_reservations_allowed_to_collections.php`, plus `description` (was always present, verify it's in Phase 3's create) | Phase 3's `create_schema` | | 4 | `fonoteka`: `widen_albums` | `golem15_fonoteka_albums` | `v1.1.0/add_music_fields_to_albums.php`, `v1.1.1/add_cover_import_failures...`, `v1.1.4/add_album_catalog_completeness...`, `v1.1.8/add_album_sync_indexes...`, `v1.2.5/add_market_price_to_albums...`, `v1.2.6/add_market_price_source...` | Phase 3's `create_schema` | | 5 | `fonoteka`: `create_artists` | `golem15_fonoteka_artists`, `golem15_fonoteka_album_artists` (+ seed Various Artists) | `v1.1.0/create_artists_tables.php`, `v1.1.0/seed_genre_and_various_artist_taxonomy.php` (artist half) | `widen_albums` | | 6 | `fonoteka`: `create_styles` | `golem15_fonoteka_styles`, `golem15_fonoteka_album_styles` | (styles table is the renamed-and-final `tags`/`album_styles`; author fresh, matching final shape) | `widen_albums` | | 7 | `fonoteka`: `create_album_ratings` | `golem15_fonoteka_album_ratings` | `v1.1.0/create_album_ratings_table.php` | `widen_albums` | | 8 | `fonoteka`: `create_album_reservations` | `golem15_fonoteka_album_reservations` | `v1.2.4/create_album_reservations_table.php` | `widen_albums` | | 9 | `fonoteka`: `create_collection_invitations` | `golem15_fonoteka_collection_invitations` | `v1.0.8/create_collection_invitations_table.php` | `widen_collections` | | 10 | `fonoteka`: `create_pending_invitation_registrations` | `golem15_fonoteka_pending_invitation_registrations` | `v1.0.8/create_pending_invitation_registrations_table.php` | `create_collection_invitations` | | 11 | `fonoteka`: `create_api_tokens` | `golem15_fonoteka_api_tokens` | `v1.0.3/create_fonoteka_api_tokens_table.php`, `v1.0.6/add_area_ids...` (renamed to collection_ids), `v1.1.7/add_oauth_client_id...` | none new (users only) | | 12 | `fonoteka`: `create_oauth_tables` | `golem15_fonoteka_oauth_clients`, `_oauth_auth_codes`, `_oauth_refresh_tokens` | `v1.1.7/create_oauth_tables.php`, `v1.1.9/add_scope_ceiling...` | `create_api_tokens` (refresh_tokens FK) | | 13 | `fonoteka`: `create_csv_import_tables` | `golem15_fonoteka_csv_imports`, `_csv_import_rows` | `v1.1.2/create_csv_import_tables.php`, `v1.1.6/add_csv_import_mode.php` | `widen_collections`, `widen_albums` | | 14 | `fonoteka`: `create_credentials_tables` | `_user_ai_credentials`, `_org_ai_credentials`, `_user_discogs_credentials`, `_org_discogs_credentials` | `v1.0.4`, `v1.0.5`, `v1.1.3` (both files) | `create_organisations` (org FK) | | 15 | `fonoteka`: `create_notifications` | `golem15_fonoteka_notifications` | `v1.2.1/create_notifications_table.php` | none new | | 16 | `fonoteka`: `create_wishlist_subscriptions` | `golem15_fonoteka_wishlist_subscriptions` | `v1.2.2/create_wishlist_subscriptions_table.php` | `widen_collections` | | 17 | `fonoteka`: `create_wishlist_digest_queue` | `golem15_fonoteka_wishlist_digest_queue` | `v1.2.3/create_wishlist_digest_queue_table.php` | `widen_collections` | **Genres table needs no migration in this phase** — Phase 3's `create_schema` + `seed_genres` already produced the final shape (verified: live schema for `golem15_fonoteka_genres` is `id, name, slug, description, created_at, updated_at` with a `slug` index — byte-identical to Phase 3's migration). **`collection_editors` and `user_collection_contexts` tables need no widening** — Phase 3's `create_schema` already created them in their exact final shape (verified against live schema: `role`/`granted_at`/`granted_by`/unique(collection_id,user_id) and unique(user_id) respectively already match). ### `widen_users` finding No Fonoteka model's fillable/hidden/casts/relations reads any `users` column beyond `id` (FK target only). The real, final PHP `users` table has 52 columns (2FA, GDPR consent, OAuth social login, device auth, etc.) — none of which any of the 25 Fonoteka models touch. **This phase's `widen_users` migration can legitimately be empty or omitted entirely** unless the planner chooses to pre-add `organisation_id`/`organisation_role` now (cheap, additive, unblocks Phase 7 from a second users-widening pass) — flagged as an **open question** below rather than decided here, since CONTEXT.md named `widen_users` as one of three concrete widen targets without specifying its content, and the phase boundary text ("plus the golem15.user tables they depend on") suggests only FK-target existence is required, not column parity with the eventual full User model. ### Soft-delete + unique audit (Pitfall 11 — the whole audit, not a sample) Checked all 25+ tables (`deleted_at` presence × non-PK unique constraint presence), verified against live schema: | Table | Has `deleted_at`? | Has non-PK unique constraint? | Conflict? | |-------|---|---|---| | `golem15_fonoteka_albums` | Yes | No | No — nothing to protect | | `golem15_fonoteka_collections` | Yes | Yes: `public_token` (nullable) | **Yes — the one real case.** PHP's own migration uses a **plain** `UNIQUE` constraint, no partial index (`WHERE deleted_at IS NULL`). Match PHP exactly (plain unique) for parity; the practical collision risk is negligible (32-char random token), but the delete-then-recreate test (success criterion 5) should target this column specifically: soft-delete a Collection with a set `public_token`, then attempt to set the same literal token value on a new/different Collection, and assert PHP's actual behavior (constraint violation) is reproduced — this is "matching PHP's actual constraints" per the Claude's-Discretion note, not "fixing" it with a partial index PHP doesn't have. | | All other 23 tables | No `deleted_at` (hard-deleted or never deleted) | various | No — soft-delete + unique intersection doesn't apply | **Practical implication:** the success-criterion-5 "delete-then-recreate test... for every soft-deletable uniquely-keyed table" has exactly **one** target table (`golem15_fonoteka_collections.public_token`) in the real schema, not an open-ended set. Size the plan accordingly. ## CLI-03: Already Implemented `lagoon.Migrate(gdb, plugins)`, `lagoon.RollbackLast(gdb, plugins, pluginID)`, `lagoon.Status(gdb, plugins)`, and the `migrate`/`migrate:rollback --plugin`/`migrate:status` bonfire commands (`lagoon/commands.go`) were built in Phase 3/4 and are functionally complete: `RollbackLast` resolves an empty `--plugin` flag to the last-migrated plugin, validates the plugin is activated and has migrations, and calls gormigrate's own `RollbackLast()` against that plugin's isolated `summer_migrations_` history table (via `HistoryTableName`). This **already satisfies** REQUIREMENTS.md's CLI-03 wording verbatim. **What remains for this phase:** a test proving isolation end-to-end now that two plugins (`user`, `fonoteka`) both have real migration sets with several migrations each — `summer migrate:rollback --plugin=fonoteka` must roll back only Fonoteka's last migration (e.g. `create_wishlist_digest_queue`) and leave `user`'s history table and `fonoteka`'s earlier migrations untouched. No new `lagoon` code is expected; this is a verification task, sized as part of the unit-test plan (or a smoke test earlier), not new command-writing. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Many-to-many with extra pivot columns | Custom join-table query layer from scratch | GORM `SetupJoinTable` + explicit sync function for writes (Association mode has no pivot-column write path) | GORM's own relation loading (with `ORDER BY`) still works for reads once `SetupJoinTable` is registered; only writes need the explicit path | | Thumbnail generation | Hand-written crop/scale math on `image.Image` | `disintegration/imaging` (`Fill`/`Fit`/`Resize`) | Exactly matches Winter's three thumb modes; pure Go, no cgo, single dependency | | Rule-string validation | A bespoke mini-parser for Laravel rule strings | `go-playground/validator`'s `Var()` API with a small string of go-playground tags produced by translating the PHP string once per field | Already the named STACK.md decision; hand-rolling reinvents an established, well-tested validation engine | | Encrypted column storage | Ad-hoc `crypto/aes` calls scattered per model | One `lagoon.Encrypted` `sql.Scanner`/`driver.Valuer` type, used by all 4 credential models | Consistent key derivation (HKDF from `app.key`), consistent versioned ciphertext format, one place to audit for the security review | | File storage abstraction | Direct `os.WriteFile`/`os.ReadFile` calls per attachment site | `gocloud.dev/blob` (`fileblob` in prod/dev, `memblob` in tests) | Already the named D-15 decision; gives a clean local↔S3/GCS swap path later with zero call-site changes | **Key insight:** every "don't hand-roll" item in this phase already has a named library decision in CONTEXT.md or STACK.md — the research task was to verify each library's actual API shape against the specific PHP behavior it must reproduce (pivot columns, thumb naming, rule grammar), not to discover new libraries. ## Common Pitfalls (See `.planning/research/PITFALLS.md` Pitfalls 3, 4, 5, 6, 7, 11, 12, 13 — all directly apply to this phase and are not re-derived here. Two phase-specific additions from this session's research:) ### Pitfall: go-playground/validator's `min`/`max` tags don't parse a custom string-backed Money type **What goes wrong:** Applying `validate:"omitempty,min=0,max=999999.9999"` directly to a `MoneyString` (or similarly named string-backed) type either fails to compile validation against it meaningfully or silently no-ops, because the built-in `hasMinOf`/`hasMaxOf` functions dispatch on Go kind (numeric vs. string-length), and a named string type validates as a **string length** bound (`min=0` chars, `max=999999.9999` — not even a valid integer length), not a numeric value bound. **Why it happens:** The PHP `numeric|min:0|max:999999.9999` rule operates on the raw numeric value; the Go port's "never float64 in the JSON path" decision (correctly) uses a string-backed type for the persisted/serialized representation, creating a type mismatch with validator's kind-based dispatch. **How to avoid:** Validate the parsed `float64` (or a decimal intermediate) against `min`/`max` **before** constructing the `MoneyString`, or register a custom validation function (`validate.RegisterValidation("money_range", func(fl validator.FieldLevel) bool {...})`) that parses the string first. **Phase to address:** Data layer (this phase) — the Money cast's validation wiring, not the cast itself. ### Pitfall: `oneof` requires single-quoting for any allowed value containing a space, and Album's own `FORMATS` constant has one (`EP 7"`) **What goes wrong:** A naive `oneof=LP 2LP CD 2CD MC Box EP 7"` tag string splits on whitespace and treats `EP`, `7"` as two separate allowed values, neither of which matches the real value `EP 7"`. **Why it happens:** Copying the PHP `Rule::in([...])` array items directly into a space-joined `oneof=` tag without noticing one value itself contains a space. **How to avoid:** Wrap any oneof value containing whitespace in single quotes: `oneof='EP 7"' LP 2LP CD 2CD MC Box` (verified against the validator library's own `parseOneOfParam2` source, which strips single-quote delimiters after splitting — the literal `"` character inside the quoted value is not a delimiter and passes through safely). **Phase to address:** Data layer (this phase) — `Album.Rules()["format"]`. ## Code Examples ### Winter `File` thumb filename + partition rule (D-17, verified against `vendor/winter/storm/src/Database/Attach/File.php`) ```php // Source: winter/storm File.php:634-646 (getThumbFilename) and :1046-1049 (getPartitionDirectory) // Thumb filename: thumb______. // e.g. thumb_42_200_200_0_0_crop.jpg — matches SerializesFonoteka's getThumb(200, 200, ['mode' => 'crop']) implode('_', ['thumb', (string) $this->id, (string) $width, (string) $height, (string) $options['offset'][0], (string) $options['offset'][1], (string) $options['mode'] . '.' . (string) $options['extension']]); // Partition directory: first 9 chars of disk_name, split into 3 groups of 3, joined by '/' implode('/', array_slice(str_split($this->disk_name, 3), 0, 3)) . '/'; // e.g. disk_name "abc123xyz.jpg" -> partition "abc/123/xyz/" // Full public path: getPublicPath() . partitionDirectory . filename // Full disk path: getStorageDirectory() . partitionDirectory . filename (getStorageDirectory: "public/" or "protected/") ``` **Go port:** a `Thumb(w, h, mode) string` method on the framework `File` struct reproduces both functions verbatim (string formatting only, no external dependency needed for the naming itself — only the actual pixel resize needs `disintegration/imaging`). ### `system_files` verified column set (D-14, `[VERIFIED: live Postgres introspection]`) ```sql -- id, disk_name, file_name, file_size, content_type, title, description, -- field, attachment_id, attachment_type, is_public, sort_order, -- created_at, updated_at, metadata -- NOTE: attachment_id is VARCHAR(255), not integer — Winter's morphMany -- stores the owner's primary key as a STRING in this column, not an int FK. ``` ### `disintegration/imaging` thumbnail generation matching Winter's modes ```go // Source: pkg.go.dev/github.com/disintegration/imaging (verified function signatures) import "github.com/disintegration/imaging" 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) // Winter mode=crop case "exact": return imaging.Resize(src, w, h, imaging.Lanczos) // Winter mode=exact (stretch) default: // "auto" return imaging.Fit(src, w, h, imaging.Lanczos) // Winter mode=auto (contain) } } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|---------------|--------| | ARCHITECTURE.md/PITFALLS.md prose mentioning "goose" as the migration tool | `gormigrate` | Recorded as authoritative in STATE.md's Phase 3 decision log ("treat gormigrate as authoritative when phases 3 and 5 are planned") | This phase's migrations must use `gormigrate`'s `[]*gormigrate.Migration{ID, Migrate, Rollback}` shape (already the pattern in `fonoteka.go/plugins/golem15/fonoteka/migrations.go`), not goose files | **No other "deprecated/outdated" findings** — this phase ports a stable, historical PHP codebase onto a current, already-decided Go stack; there is no legacy-Go-approach angle here. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | `widen_users` can legitimately be empty/omitted for this phase's own success criteria (no Fonoteka model touches non-`id` user columns) | Squashed Migration List, `widen_users` finding | Low — if wrong, the planner adds columns later in a follow-up ALTER; nothing else in this phase depends on `widen_users` having content, since only `id` is an FK target | | A2 | `golem15_user_organisations` migration belongs in the `user` plugin's set, not `fonoteka`'s | Squashed Migration List | Low-medium — if the planner instead puts it in `fonoteka` with a defensive existence-guard (mirroring PHP's own `Schema::hasTable` guard), functionality is identical; only the migration-ownership convention changes. Flagged because it affects which plugin's migration file the planner writes it into. | | A3 | `disintegration/imaging` (not actively committed to since 2023, though not archived) is an acceptable pick despite low recent commit activity, because the feature set needed (Fill/Fit/Resize) is complete and stable | Standard Stack, D-18 | Low — the library does exactly what's needed with zero cgo and one clean transitive dependency; if a security issue surfaced in it later, the maintained `kovidgoyal/imaging` fork is a documented drop-in alternative | | A4 | The `styles`/`album_styles` tables need a fresh Go migration (not folded from an existing Phase-3 artifact) since Phase 3 only created genres/collections/albums/collection_editors/user_collection_contexts | Squashed Migration List | Low — directly confirmed by reading `fonoteka.go/plugins/golem15/fonoteka/migrations.go`'s actual Phase-3 migration content in this session | **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 (RESOLVED) 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. **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. **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 | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Docker | D-02 schema-diff test (testcontainers), fuzz tests | Yes | Confirmed working in this session | — | | PostgreSQL (via testcontainers `postgres:16-alpine`) | All model/migration tests | Yes | 16.15, confirmed pl-PL ICU locale support already in use (`lagoon/postgres_test.go`) | — | | PHP + Composer + `pdo_pgsql` | Producing the D-02 golden snapshot (one-time, offline) | Yes | PHP 8.x (Winter/Laravel 9.52.21), `pdo_pgsql` extension loaded | Not needed at CI time — snapshot is generated once and committed; CI never needs PHP | | `disintegration/imaging` Go module | Thumbnail generation (D-15/D-17/D-18) | Yes (via `go get`) | v1.6.2, confirmed on `proxy.golang.org` | — | **Missing dependencies with no fallback:** none. **Missing dependencies with fallback:** none — everything needed for this phase is either already present or trivially `go get`-able. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Go stdlib `testing` + `testify` (assert/require) + `testcontainers-go`/`modules/postgres` v0.44.0 | | Config file | none — plain `func TestX(t *testing.T)`, `TestMain` pattern per `lagoon/postgres_test.go` (pl-PL ICU locale container) | | Quick run command | `go test ./... -short` (skips testcontainers-backed tests per the existing `testShort()` convention) | | Full suite command | `go test ./...` (starts real Postgres via testcontainers) | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | DATA-03 | Cascading soft-delete (Collection→Albums) in one transaction | integration | `go test ./plugins/golem15/fonoteka/... -run TestCollectionBeforeDeleteCascadesAlbums` | ❌ Wave 0 | | DATA-04 | 3+ artist album round-trips `sort_order`; CollectionEditor pivot columns round-trip | integration | `go test ./plugins/golem15/fonoteka/... -run TestAlbumArtistsOrderRoundTrip` | ❌ Wave 0 | | DATA-05 | Rule-string → 422 map for Album/Collection/Artist/Style/Genre/Settings | unit + integration (unique:table needs DB) | `go test ./lagoon/... -run TestValidate` | ❌ Wave 0 | | DATA-06 | Fillable fuzz test (unknown fields never persisted) | integration | `go test ./plugins/golem15/fonoteka/... -run TestAlbumWriteServiceFillFuzz -fuzz` | ❌ Wave 0 | | DATA-07 | Money cast round-trips PHP ceiling/blank cases as fixed string; encrypted cast round-trips + hidden | unit + integration | `go test ./lagoon/... -run TestEncrypted`, `go test ./plugins/golem15/fonoteka/models/... -run TestMoneyString` | ❌ Wave 0 | | DATA-08 | Thumb filename matches Winter's exact rule; file delete-then-recreate on soft-deleted owner keeps blob | unit + integration | `go test ./lagoon/attach/... -run TestThumbFilename`, `-run TestFileLifecycle` | ❌ Wave 0 | | DATA-09 | Schema diff: Go migrations up == PHP schema snapshot | integration | `go test ./parity/... -run TestSchemaMatchesPHPSnapshot` | ❌ Wave 0 (snapshot file itself also ❌) | | DATA-10 | Pagination envelope shape (`{data, meta{...}}`, no `links`) | unit | `go test ./lagoon/... -run TestPaginate` | ❌ Wave 0 | | DATA-11 | Cross-plugin callback fires without editing owning model; companion migration | integration | `go test ./plugins/... -run TestCrossPluginCallback` | ❌ Wave 0 | | CLI-03 | `migrate:rollback --plugin=fonoteka` isolates to Fonoteka's own history table | integration | `go test ./cmd/summer/... -run TestRollbackScopedToPlugin` | ❌ Wave 0 | ### Sampling Rate - **Per task commit:** `go test ./... -short` - **Per wave merge:** `go test ./...` (full, real Postgres via testcontainers) - **Phase gate:** Full suite green before `/gsd:verify-work`, plus the D-02 schema-diff test green ### Wave 0 Gaps - [ ] `fonoteka.go/parity/testdata/php_schema_snapshot.sql` — the committed D-02 golden snapshot (generate once via the reproducible method above) - [ ] `lagoon/fill_test.go`, `lagoon/validate_test.go`, `lagoon/encrypted_test.go`, `lagoon/paginate_test.go` — framework-level unit tests for the new `lagoon` primitives this phase adds - [ ] `lagoon/attach/` package itself does not exist yet — this phase creates it (File model, blob wiring, Thumb()) - [ ] Existing test infra (`lagoon/postgres_test.go` TestMain pattern, `fonoteka.go/parity` TestMain) is reused, not rebuilt ## Security Domain ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-------------------| | V2 Authentication | No | Out of scope — Phase 7 | | V3 Session Management | No | Out of scope — Phase 7/8 | | V4 Access Control | Partial | Mass-assignment fillable allow-list (D-05/D-06) is an access-control-adjacent control at the model layer; full authorization scoping is Phase 6/12 | | V5 Input Validation | Yes | go-playground/validator rule translation (D-09), fillable allow-list enforcement (D-05/D-06) | | V6 Cryptography (STORAGE) | Yes | AES-256-GCM via `lagoon.Encrypted`, key derived via HKDF from `app.key` (D-10..D-13) — never hand-rolled AES, matching the PHP source's own explicit "Hand-rolled AES is forbidden in this repository" comment on `UserDiscogsCredential`/`OrgDiscogsCredential` | ### Known Threat Patterns for this stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|----------------------| | Mass assignment via unknown/server-owned fields (`collection_id`, `market_price_source` on Album; `public_token`/`public_enabled`/`kind` on Collection) | Tampering / Elevation of Privilege | Two-layer fillable: model `Fillable()` backstop + narrower per-service allow-list (`AlbumWriteService.FILL_FIELDS`), fuzz-tested (D-07) | | Accidental serialization of `token_hash`/`code_hash`/`client_secret_hash`/encrypted credential columns | Information Disclosure | `json:"-"` struct tag as a hard backstop + `Hidden()` method for a lagoon-wide "marshal every model, assert hidden absent" test (D-08); credentials additionally never reach plaintext except through explicit `.Reveal()` | | Encrypted column readable by an old/rotated key holder after rotation | Information Disclosure (if mishandled) / Denial of Service (if not) | Versioned ciphertext format (key-id prefix) + `app.previous_keys` decrypt-only fallback (D-12), same pattern as Laravel's `APP_PREVIOUS_KEYS` | | Soft-delete + unique constraint bypass allowing a "deleted" row's unique key to block a legitimate new row, or vice versa a delete-then-recreate silently succeeding when PHP would have rejected it (data integrity, not directly a STRIDE category but a parity/correctness risk with security-adjacent stakes for the `public_token` case — token reuse across a delete boundary) | Tampering (data integrity) | Match PHP's actual constraint shape exactly (plain unique, no partial index) per the audit above — do not "improve" this with a partial index PHP doesn't have, since that would be an undocumented behavior change on a token-bearing column | | `unique:table` validation check racing a concurrent write (TOCTOU) | Tampering | Not newly introduced by this phase (PHP has the same race); note for the planner that the DB-level unique constraint, not the pre-save validator check, is the actual integrity guarantee — the validator check is UX (early 422), not the security boundary | ## Sources ### Primary (HIGH confidence) - **Live Postgres 16 schema introspection** (this session): ran the actual PHP `winter:up` migration command via `php artisan winter:up` against a scratch `postgres:16-alpine` container, then `\d ` / `pg_dump --schema-only` for all 25 Fonoteka tables, `system_files`, `users`, `golem15_user_organisations`. This is the primary source for the Full Per-Model Inventory's column/index/FK facts and for answering D-02. - Direct reads of all 25 PHP model files (`/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/*.php`) - Direct reads of all 38 PHP migration files (`updates/v*/*.php`) plus `updates/version.yaml` - Direct reads of `classes/casts/MarketPriceCast.php`, `classes/AlbumWriteService.php`, `traits/SerializesFonoteka.php` - Direct read of `vendor/winter/storm/src/Database/Attach/File.php` (thumb naming, partition directory, storage directory logic — lines 440-1050) - Direct read of `config/cms.php` (storage.uploads config shape) - Direct read of `modules/system/database/migrations/2013_10_01_000002_Db_System_Files.php` and the 2025 metadata-column migration - `gorm.io/docs/many_to_many.html` (fetched live) — `SetupJoinTable` exact API - `github.com/go-playground/validator` `doc.go` and `baked_in.go` (fetched live) — confirmed no `between` tag, `oneof` single-quote parsing, `min`/`max`/`numeric`/`number`/`omitempty`/`omitnil` semantics - `proxy.golang.org` `.info` endpoints (fetched live) — exact version/date confirmation for `disintegration/imaging`, `go-playground/validator/v10`, `gocloud.dev`, `golang.org/x/image` - `pkg.go.dev/gocloud.dev/blob/fileblob` (fetched live) — `OpenBucket` signature, `Options` fields, subdirectory-key handling - `laravel.com/docs/11.x/encryption` (fetched live) — confirms AES-256-CBC + HMAC MAC format for the Phase 15 decrypt-only helper (D-10) - Existing `summercms.go` source: `lagoon/migrations.go`, `lagoon/commands.go`, `lagoon/postgres_test.go`, `pact/capabilities.go` — CLI-03's already-implemented status, existing test patterns - Existing `fonoteka.go` source: `plugins/golem15/fonoteka/{migrations.go,genre.go,plugin.go}`, `plugins/golem15/user/{migrations.go,user.go,plugin.go}`, `parity/migrate_test.go` — Phase 3 baseline to widen ### Secondary (MEDIUM confidence) - `.planning/research/ARCHITECTURE.md`, `PITFALLS.md`, `STACK.md` — prior-phase research, cross-checked against this session's live findings (no contradictions found; `gormigrate`-vs-`goose` staleness explicitly flagged and resolved per STATE.md) ### Tertiary (LOW confidence) - `pkg.go.dev/github.com/disintegration/imaging` function-signature summary (WebFetch-summarized, not raw source read) — the function signatures (`Fill`, `Fit`, `Resize`, `Thumbnail`) and filter constant list are standard/well-known for this library and were cross-checked against the confirmed go.mod (only `golang.org/x/image` as a dependency, implying pure-Go/no-cgo) but were not read from raw source in this session ## Metadata **Confidence breakdown:** - Standard stack: HIGH — all versions verified live against `proxy.golang.org`; `disintegration/imaging` verified not archived, dependency graph confirmed minimal - Architecture (schema, models, migrations): HIGH — the entire schema was verified by actually running the PHP migrations against a real Postgres instance, not inferred from reading migration files - Pitfalls: HIGH — Pitfalls 3/4/5/6/7/11/12/13 are already-established project research; the two new pitfalls (validator min/max on Money type, oneof quoting) were verified against the validator library's own source - Security domain: HIGH — directly grounded in PHP source comments that explicitly name the security invariants ("Hand-rolled AES is forbidden in this repository", "$hidden... must never be returned to the SPA") **Research date:** 2026-09-18 **Valid until:** 30 days for the Go library version pins (stable ecosystem); the live-verified PHP schema facts do not expire unless the PHP source changes (not expected mid-port)