81 KiB
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>
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 diffsinformation_schema/pg_catalogagainst 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.Fillcopies 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_FIELDSis a strict subset ofAlbum::$fillable: nocollection_id, nomarket_price_source). The model list is the backstop, the service list is the real boundary.json.Unmarshalinto 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.Fillhas 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 declareHidden() []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 portedSerialize*functions, as PHP'sSerializesFonotekadoes. The "explicit per-call override" is a named, greppable function (Reveal-style), not a serializer option. - D-09: Models declare
Rules() map[string]stringwith the PHP rule strings copied verbatim.lagoontranslates 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 throughphrasebook.unique:tableis 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
encryptedpayload (base64 JSON with iv/value/mac, AES-256-CBC + HMAC underAPP_KEY) for the Phase 15 cutover import to re-encrypt; it is not wired into the cast. - D-11: The key is
app.keyin 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 soapp.keycan serve other purposes later without key reuse. Asummer key:generatecommand prints a fresh key. - D-12: Ciphertext is versioned: a format/key-id prefix, nonce, ciphertext+tag. Config accepts
app.previous_keysas decrypt-only keys (Laravel'sAPP_PREVIOUS_KEYSequivalent). No re-encrypt command in this phase. - D-13: Encrypted columns are typed
lagoon.Encryptedon the model: Scanner/Valuer decrypt on read and encrypt on write;MarshalJSON,String()andGoString()always emit a redaction; plaintext is only reachable through an explicit.Reveal(). Applies toapi_keyonUserAiCredentialandOrgAiCredentialand to the secret columns ofUserDiscogsCredentialandOrgDiscogsCredential; research lists the exact columns. [Confirmed below:api_key,api_key,token,tokenrespectively.]
Attachments (DATA-08)
- D-14: Attachments use Winter's
system_filestable shape and name (disk_name,file_name,file_size,content_type,title,description,field,attachment_id,attachment_type,is_public,sort_order, timestamps).attachment_typekeeps 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 andFilemodel 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/blobis 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
fileblobrooted at the samestorage/app/uploadstree PHP uses, with bucket URL and public path prefix from astorage.uploads.*config section mirroringcms.php'sstorage.uploads. Winter'sdisk_namepartition path (public/xxx/yyy/zzz/<disk_name>) 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.memblobin unit tests. - D-17:
Thumb(w, h, mode)reproduces Winter's thumb filename and location (next to the original,thumb_<id>_<w>_<h>_0_0_<mode>.<ext>; research confirms the exact rule). Thumbs copied at cutover are reused, andthumb_urlstays 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/imagingv1.6.2.] - D-19: Deletion follows Winter: soft-deleting an owner keeps
system_filesrows 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/AfterDeleteas 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_orderandCollectionEditor(GORMSetupJoinTablevs 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/(orlagoonif generic) that reproducesMarketPriceCast::get(null for null/blank/non-numeric, else 4-decimal string) and leaves normalisation to theAlbumbefore-save hook, as PHP does. Neverfloat64in the JSON path. - Jsonable cast shape, including
[]vsnullbehavior per column (Pitfall 4). - Pagination helper API; the envelope is fixed:
{data, meta{current_page,last_page,per_page,total}}, nolinks. - 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 (ArtistResolverinbeforeSave) becomes a callback registered fromclasses/, 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_filesrows 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. </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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_<plugin> 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_usersmust be strictly additive; never touch or reinterpret existinguserscolumns. - 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/imagingis this phase's one new named dependency (D-18).go vetandgo 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 —gormigrateis 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
SerializesFonotekabyte-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, neverclasses//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:
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/<module>/@v/<version>.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_<id>_<w>_<h>_0_0_<mode>.<ext>, 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):
// 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):
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:
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)
// 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 losessort_order/role/granted_at/granted_by— write the join table directly (see pattern above). AutoMigratefor 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 —gormigratewith real up/down.Migrate()/.RollbackLast()files is the only mechanism, everywhere, including tests.- Validating the Money field's
maxbound against the formatted string:"999999.9999"compared lexicographically against"1000000.0000"gives the wrong answer — validate the numeric value, format afterward. - Treating
golem15_user_organisationsas a Fonoteka-owned table: it is logically owned bygolem15.user(mirrors the real PHP plugin that created it) even though onlyfonoteka's credential tables use it in this phase; its create migration belongs in theuserplugin's migration set, notfonoteka'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):
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:
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_<plugin> 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)
// Source: winter/storm File.php:634-646 (getThumbFilename) and :1046-1049 (getPartitionDirectory)
// Thumb filename: thumb_<id>_<width>_<height>_<offsetX>_<offsetY>_<mode>.<ext>
// 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])
-- 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
// 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)
-
RESOLVED (user decision, 2026-09-18):
widen_usersis deferred to Phase 7 — nowiden_usersmigration ships in Phase 5. (Original question: doeswiden_usersneedorganisation_id/organisation_rolenow, or does Phase 7 add them?)- What we know: no Fonoteka model in this phase reads them; the columns exist in the real PHP
userstable and are cheap/additive. - What's unclear: whether the planner wants to front-load this to avoid a second
usersALTER 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_usersmigration 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.
- What we know: no Fonoteka model in this phase reads them; the columns exist in the real PHP
-
RESOLVED (user decision, 2026-09-18): dedicated typed table
golem15_fonoteka_settings, not Winter's genericsystem_settingsmechanism. (Original question: where does theSettingsmodel'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'sSettingsModelbehavior serializes the whole settings array into onesystem_settings.valuetext column keyed byitem = '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 giveSettingsits own tiny dedicated table with a typedsearch_use_typesense BOOLEANcolumn (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).
- What we know: only one field,
-
RESOLVED: no FK constraints on
notifications/wishlist_subscriptions/wishlist_digest_queue'suser_id/collection_id— matches PHP exactly, not fixed as an oversight. (Original question: exact FK columns to declare fornotificationsandwishlist_subscriptions/wishlist_digest_queue'suser_id/collection_id.) PHP deliberately omits FKs on these (confirmed: no->foreign()calls in the live schema forgolem15_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_idcolumns, 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 newlagoonprimitives this phase addslagoon/attach/package itself does not exist yet — this phase creates it (File model, blob wiring, Thumb())- Existing test infra (
lagoon/postgres_test.goTestMain pattern,fonoteka.go/parityTestMain) 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:upmigration command viaphp artisan winter:upagainst a scratchpostgres:16-alpinecontainer, then\d <table>/pg_dump --schema-onlyfor 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) plusupdates/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.phpand the 2025 metadata-column migration gorm.io/docs/many_to_many.html(fetched live) —SetupJoinTableexact APIgithub.com/go-playground/validatordoc.goandbaked_in.go(fetched live) — confirmed nobetweentag,oneofsingle-quote parsing,min/max/numeric/number/omitempty/omitnilsemanticsproxy.golang.org.infoendpoints (fetched live) — exact version/date confirmation fordisintegration/imaging,go-playground/validator/v10,gocloud.dev,golang.org/x/imagepkg.go.dev/gocloud.dev/blob/fileblob(fetched live) —OpenBucketsignature,Optionsfields, subdirectory-key handlinglaravel.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.gosource:lagoon/migrations.go,lagoon/commands.go,lagoon/postgres_test.go,pact/capabilities.go— CLI-03's already-implemented status, existing test patterns - Existing
fonoteka.gosource: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-goosestaleness explicitly flagged and resolved per STATE.md)
Tertiary (LOW confidence)
pkg.go.dev/github.com/disintegration/imagingfunction-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 (onlygolang.org/x/imageas 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/imagingverified 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)