Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md
2026-09-18 15:23:21 +02:00

160 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5: Data layer full fidelity - Context
**Gathered:** 2026-09-18
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 5 ports the whole Płytarium data layer: all 25 models of `golem15.fonoteka` (plus the `golem15.user` tables they depend on) with matching table names, columns, indexes, defaults, relations, casts and lifecycle hooks, and a Go migration set whose final schema equals PHP's. Requirements DATA-03 to DATA-11 and CLI-03. Roadmap mode: mvp. The phase is security-load-bearing: mass-assignment boundaries and encrypted-at-rest credentials are named invariants in the PHP source, so the security-review agent runs on it and the DTO-vs-model convention applies from the first model.
Two repos. `summercms.go` grows `lagoon` with what the models pull in: lifecycle hooks and transactional soft-delete cascade, relation helpers with ordered results and pivot models, `Fill` with the fillable allow-list, the hidden discipline, rule-string validation with the Laravel-shaped 422 map, casts (jsonable, money as fixed 4-decimal string, `lagoon.Encrypted`), the `system_files` attachment model with blob storage and thumbnails, the pagination envelope, the GORM callback registry for cross-plugin extension, and `migrate:rollback --plugin` hardening (CLI-03). It still knows nothing about Płytarium. `fonoteka.go` restructures its two flat Phase 3 plugins into the Winter directory layout and receives the models, the migration set, the write services' fill boundary and the tests.
Not in this phase: any HTTP route, including the photo-upload endpoint (Phase 12, API-02), the guarded outbound fetch for cover URLs (HTTP-07), Discogs cover import (Phase 14), River jobs, Centrifugo broadcasting and Typesense indexing behind `Album`'s PHP traits (Phase 11), the user plugin's auth flows (Phase 7), the OAuth provider storage implementation (Phase 8; only the OAuth *models and tables* land here), admin form schemas (Phase 9), and the production data import (Phase 15).
Known drift to correct in planning: the roadmap says "27 migrations"; `updates/version.yaml` now lists 38 PHP migration files across 26 versions. The count is not an acceptance number (see D-01).
</domain>
<decisions>
## Implementation 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 (running the PHP migrations against a scratch Postgres vs. another route) is research; if the PHP migrations do not run cleanly on Postgres, research reports that before planning.
- **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 (required, nullable, integer, numeric, between, min, max, in, unique:table, plus whatever research finds in the 25 models) 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.
### 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 from the PHP `$casts`.
### 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/<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. `memblob` in 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 from Winter Storm's `File` model) and generates lazily on first call through blob when the thumb is missing. 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.
- **D-18:** The resize library is research's pick, recorded in RESEARCH.md as the phase decision naming the dependency: pure Go, maintained, no cgo/libvips. stdlib `image/*` plus `golang.org/x/image/draw` is the baseline to beat.
- **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.
- 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.
- 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.
### Folded Todos
- **Verify the models-leaf layout rule against keios.eu and a jz/pxpx plugin before Phase 5** (`.planning/todos/pending/verify-models-leaf-rule.md`). Runs as the first task of the phase, before 24 models are laid out under the rule: repeat the `models/ → classes/` (and any sibling) `use`-edge probe on one keios.eu stack plugin with models and services (`/media/nvme/dev/golem15/keios.eu/plugins/golem15/user` or `paymentgateway`), one jz plugin and one pxpx plugin; classify each edge as cast / service-calling hook / other. Any "other" edge means `.planning/notes/plugin-layout-winter-directories.md` is amended before the bulk port proceeds.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### The PHP data layer being ported
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/*.php` — the 25 models: `$table`, `$fillable`, `$guarded`, `$hidden`, `$casts`, `$jsonable`, `$dates`, `$rules`, relations, hooks. `Album.php` is the densest (constants, money cast, jsonable, soft delete, belongsToMany artists, attachMany photos, `beforeSave` with `ArtistResolver`).
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/version.yaml` and `updates/v*/` — 38 migration files across 26 versions; the history D-01 squashes and the seeds D-04 ports.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php` — money cast semantics and the docblock explaining the blank-string case.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php` — `FILL_FIELDS` and the fill/fillEmpty/overwrite boundary that D-05 and D-07 port and fuzz.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserAiCredential.php`, `OrgAiCredential.php`, `UserDiscogsCredential.php`, `OrgDiscogsCredential.php` — `encrypted` casts and `$hidden` (D-10 to D-13).
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php` — payload builders; lines ~190–215 for the photo payload, `getThumb(200, 200, ['mode' => 'crop'])` and `relativeMediaUrl` (D-08, D-16, D-17).
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php` lines ~91–107 — `attachMany photos` (ordered) and public `attachOne image`.
- `/media/nvme/dev/golem15/fonoteka/config/cms.php` lines ~300–335 — `storage.uploads` / `media` / `resized` disks and paths (D-16).
- Winter Storm `File` model and `Winter\Storm\Database\Attach\File` in `/media/nvme/dev/golem15/fonoteka/vendor/winter/storm` — `disk_name` partitioning, `getPath`, `getThumb` naming, delete behavior (D-14, D-17, D-19).
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/` and its `updates/` — the user-plugin tables that `widen_users` must match.
### Go code this phase builds on
- `lagoon/connection.go`, `lagoon/migrations.go`, `lagoon/commands.go`, `lagoon/order.go` — shared `*sql.DB`, per-plugin gormigrate runner, `migrate:*` commands, Polish order helper.
- `pact/capabilities.go` — `HasModels`/`HasMigrations` and friends.
- `../fonoteka.go/plugins/golem15/fonoteka/migrations.go` and `../fonoteka.go/plugins/golem15/user/migrations.go` — the shipped, never-edited Phase 3 migrations (`202609170001_create_schema`, `202609170002_seed_genres`) that D-03 appends to.
- `../fonoteka.go/plugins/golem15/fonoteka/*.go` and `../fonoteka.go/plugins/golem15/user/*.go` — the flat Phase 3 plugins to restructure into the Winter layout.
- `internal/build/` — the `models/`-is-a-leaf enforcement shipped in Phase 4 (P4 D-11) and the `make:model`/`make:migration` stubs that should match what this phase hand-writes.
### Layout and architecture decisions
- `.planning/notes/plugin-layout-winter-directories.md` — directory mapping and the models-leaf rule (casts into `models/`, service-calling hooks out to `classes/`).
- `.planning/todos/pending/verify-models-leaf-rule.md` — the folded todo with its probe commands.
- `.planning/research/ARCHITECTURE.md` §Pattern 3 (3a events, 3b GORM callback registry, 3c companion structs over shared tables), §Migration Flow, §Anti-Pattern 4. Text that says goose is superseded by gormigrate.
- `.planning/research/PITFALLS.md` §Pitfall 3 (DTO vs model), §Pitfall 4 (nil vs `[]`), §Pitfall 5 (float vs fixed-decimal string), §Pitfall 7 (tri-state bool), §Pitfall 11 (soft delete + unique), §Pitfall 12 (pivot columns), §Pitfall 13 (no AutoMigrate), §Security Mistakes, §Integration Gotchas row on gocloud.dev/blob URL shape.
- `.planning/research/STACK.md` §GORM: plain structs, §Migration tooling, §Version Compatibility (gorm v1.31.2, validator v10.30.4, gocloud.dev v0.46.0, testcontainers v0.44.0).
- `.planning/research/FEATURES.md` — model-feature inventory from the PHP source.
### Prior phase decisions that bind this phase
- `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md` D-02, D-06, D-11, D-16, D-17, D-18, D-19.
- `.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md` D-10, D-11, D-12, D-13.
- `.planning/phases/01-framework-kernel-foundation/01-CONTEXT.md` D-06, D-08, D-10 (config namespaces, env mapping, fail-fast boot).
- `.planning/PROJECT.md` §Constraints, §Key Decisions; `.planning/REQUIREMENTS.md` DATA-03 to DATA-11, CLI-03; `.planning/ROADMAP.md` Phase 5 section.
- `CLAUDE.md` (repo root) §GSD workflow rules.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lagoon`: connection on the shared pgx-stdlib `*sql.DB`, per-plugin gormigrate runner with per-plugin history tables, `migrate`, `migrate:rollback --plugin`, `migrate:status`, the `pl-PL` ICU order helper and its testcontainers Postgres test harness.
- `phrasebook` (Phase 4) for translated validation messages; `compass` for `app.key`, `app.previous_keys` and `storage.uploads.*`; `backpack` to publish the blob bucket and file service; `bonfire` for `key:generate`.
- `internal/build` leaf-import check and `make:model`/`make:migration` stubs.
- `fonoteka.go/parity` testcontainers `TestMain`: the place the schema-diff test and the service-level fuzz tests can share a Postgres with.
### Established Patterns
- No AutoMigrate anywhere, tests included; shipped migrations are never edited; each plugin's set rolls back without touching another's history.
- Fail boot loudly on misconfiguration (now also: bad `app.key`, untranslatable rule string, unconfigured uploads bucket).
- Response DTOs and explicit serializers, never the GORM model; `[]` not `null`; no generic envelope.
- Framework never imports the app; anything Płytarium-specific (money cast semantics, write services, morph names) lives in `fonoteka.go`.
- New dependencies need naming: `gocloud.dev` is already decided; the image resize library is named by this phase's research (D-18). Nothing else without a decision note.
### Integration Points
- The Phase 3 plugins in `fonoteka.go` are flat single packages; this phase moves them to `models/`, `classes/`, `controllers/`, `middleware/`, `updates/` with `plugin.go`/`routes.go` at the root, keeping the genres route and its parity fixture green throughout.
- The framework's `system_files` migration set must order before every plugin set.
- `Album`'s Scout and Broadcastable behavior are seams only; Phase 11 attaches them through the callback registry built here.
- OAuth models and tables land here; the zitadel/oidc storage that uses them is Phase 8.
</code_context>
<specifics>
## Specific Ideas
- PHP's real mass-assignment boundary is two-layered: `$fillable` as backstop, a narrower per-service list as the working boundary. The Go port keeps both layers rather than collapsing them into DTO shape alone, so an agent can diff `Fillable()` against the PHP array line by line.
- Rule strings, fillable lists and hidden lists are copied verbatim from PHP so conversion stays mechanical; translation to Go idiom happens once, inside `lagoon`.
- Secrets should be unable to leak by accident: `json:"-"`, a redacting `lagoon.Encrypted`, and explicit `Reveal` calls the security review can grep.
- `system_files` and the uploads tree stay Winter-shaped so cutover is a copy, and so the next Winter project port gets attachments for free.
- The user chose to take blob storage and thumbnails in this phase despite the smaller recommended cut; treat it as a deliberate choice, not scope creep, and plan capacity for it.
</specifics>
<deferred>
## Deferred Ideas
- 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.
</deferred>
---
*Phase: 5-Data layer full fidelity*
*Context gathered: 2026-09-18*