Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-VERIFICATION.md
2026-09-18 21:11:43 +02:00

172 lines
21 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 05-data-layer-full-fidelity
verified: 2026-09-18T19:07:45Z
status: passed
score: 5/5 must-haves verified
overrides_applied: 0
---
# Phase 5: Data layer full fidelity Verification Report
**Phase Goal:** All 25 Płytarium models and their squashed migration set are ported with matching relations, casts, hooks, and the fillable/hidden/encrypted-cast mass-assignment and serialization discipline. This is security-load-bearing — mass-assignment boundaries and encrypted-at-rest credential casts are named security invariants in the PHP source — apply the security-review agent and the DTO-vs-model convention from the first model onward.
**Verified:** 2026-09-18T19:07:45Z
**Status:** passed
**Re-verification:** No — initial verification
Must-haves are the five ROADMAP success criteria (they override plan-level truths). Plan `must_haves` were checked as supporting evidence. The ROADMAP goal is a data-layer outcome, not a user-story sentence (`gsd-sdk query user-story.validate` → `false`); MVP user-flow coverage is mapped from that outcome below, same pattern as Phase 3.
`05-SECURITY-REVIEW.md` maps T-05-01 through T-05-24; independent greps of `.Reveal()`, `.Association(`, and `AutoMigrate` match the review.
## User Flow Coverage
User story (phase outcome, not a planner-formatted sentence): «A Golem15 developer can persist Płytarium's 25 models on Postgres with PHP-matching schema, fillable/hidden/encrypted discipline, and Winter-shaped attachments, so the Nuxt app's future API layer can sit on a faithful data layer.»
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| Port all 25 models + squashed migrations | Tables, columns, indexes match the PHP snapshot; rollback isolates Fonoteka | 26 fonoteka `models.All()` types (25 PHP models + `AlbumArtist` pivot) + Settings dedicated table; `TestSchemaMatchesPHPSnapshot` PASS; `TestRollbackIsolatesFonotekaFullSchema` PASS | ✓ |
| Write Album/Collection/credentials | Unknown and server-owned keys never persist | `lagoon.Fill` + `AlbumFillFields`/`CollectionFillFields` + credential narrower list; `FuzzSaveAlbum`/`FuzzSaveCollection`/credential fuzz seed corpus PASS | ✓ |
| Serialize without leaking secrets | Hidden columns and Encrypted plaintext absent from JSON | `TestHiddenNeverMarshals` (26+2 models); `Encrypted.MarshalJSON`/`String`/`GoString` redact; `.Reveal()` only in tests | ✓ |
| Attach photos/image | Morph PHP class string, ordered photos, lazy Winter-named thumbs, soft-delete keeps blobs | `MorphName()` on Album/Collection; `TestAttachSmoke` PASS | ✓ |
| Outcome | Schema equals PHP (allow-listed diffs only); fill/encryption invariants hold | This session: schema-diff, rollback, money, encrypted, hidden-marshal, attach-smoke, cross-plugin callback all green | ✓ |
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| --- | ------- | ---------- | -------------- |
| 1 | All 25 models exist with matching table names, columns, indexes and defaults, and every Go migration runs up and down individually; the final schema matches PHP's (migrations are squashed per final-state table). `summer migrate:rollback --plugin=fonoteka` rolls back only that plugin's last migration. | ✓ VERIFIED | RESEARCH inventory rows 1–25 all have a Go model + `init()` `Register`. Extra: `AlbumArtist` pivot and dedicated `golem15_fonoteka_settings` (Open Question 2). Committed `parity/testdata/php_schema_snapshot.sql` is a real `pg_dump` (2245 lines, Postgres 16.15). `TestSchemaMatchesPHPSnapshot` this session PASS — diffs `information_schema` columns/uniques/FKs/`pg_indexes` with three commented `allowedDiffs` (settings table, users column-count, extra oauth_refresh_tokens.user_id FK). `TestRemainingMigrationsUpDown` and `TestRollbackIsolatesFonotekaFullSchema` PASS: last Fonoteka ID `202609180018_create_fonoteka_settings` rolls back alone; user history and earlier Fonoteka tables survive. `lagoon.Migrate(gdb, nil)` creates `system_files` (`TestMigrateRunsSystemFilesFirst` PASS). Defaults/nullability are not in the automated diff (commented P3 D-17 type-family canonicalization); behavioral tests cover the load-bearing ones (settings singleton seed, `reservations_allowed`, unique `public_token`). |
| 2 | A many-to-many relation with pivot business columns (`album_artists.sort_order`, `CollectionEditor.role/granted_at/granted_by`) round-trips correctly for a 3+ artist album; belongsTo/hasOne/hasMany relations return ordered results. | ✓ VERIFIED | `syncArtists` is delete-then-`Create` on `golem15_fonoteka_album_artists` with `SortOrder: i` — zero `.Association(` calls in production (only a forbid comment in `lagoon/relations.go`). `classes/join_tables.go` `init` registers `RegisterJoinTable` for `Album.Artists`/`AlbumArtist` and `Collection.Editors`/`CollectionEditor`; `plugin.go` `Boot` runs `classes.RegisterHooks`. This session: `TestAlbumArtistsOrderRoundTrip` PASS (3 artists, shuffle, pivot `ORDER BY sort_order`); `TestCollectionEditorsPivotRoundTrip` PASS (`role`/`granted_at`/`granted_by` via direct take + `Preload("Editors")`). Ordered attachMany photos: `TestAttachSmoke` `ORDER BY sort_order`. Note: most belongsTo/hasOne/hasMany are FK columns rather than GORM association fields (`Album` has no `Ratings`/`Reservation`/`Collection` field); reads that need order use explicit `ORDER BY`, matching RESEARCH's "not relation-preload magic" for pivots. |
| 3 | The fill boundary of the PHP write services (at minimum Album, Collection and the four credential models) is fuzzed against real Postgres with random extra and server-owned keys, asserting nothing outside the allow-list is persisted (service-level, this phase). Serialization honors the hidden deny-list with an explicit per-call override. | ✓ VERIFIED | Two-layer fill: `Album.Fillable()` includes `collection_id`/`market_price_source`; `AlbumFillFields` excludes them (`TestSaveAlbumFillFieldsSubset`). `SaveAlbum`/`SaveCollection` call `lagoon.Fill` with the narrower list. Credential fuzz uses `credentialWriteFields` stripping `user_id`/`organisation_id`/`api_key`/`token`. This session: `FuzzSaveAlbum`/`FuzzSaveCollection`/`FuzzSaveUserAiCredential` seed corpus PASS against testcontainers Postgres; `FuzzFill` (framework fixture) PASS under `-short`. `json.Unmarshal` into GORM models is absent from production plugin code. Hidden: `json:"-"` + `Hidden()`; `classes.TestHiddenNeverMarshals` walks 26 fonoteka + 2 user models (PASS). Override is greppable `Encrypted.Reveal()` (D-08), not a serializer flag — production call sites: none. HTTP DTO fuzz is Phase 12 per this same criterion. |
| 4 | The money cast round-trips the PHP ceiling and blank-string cases as a fixed 4-decimal JSON string (never `float64`), and an encrypted-at-rest credential column is AES-GCM encrypted at rest and hidden from serialization. | ✓ VERIFIED | `MoneyString` is `string` + `big.Rat.FloatString(4)`; no `float64` in `fonoteka.go` plugin/model code. `Album.BeforeSave` → `stampMarketPrice`. This session: `TestSaveAlbumMoneyNormalization` PASS (`""`/`nil`/`"not-a-price"` → SQL NULL/`""`; `"12.55555"` → `"12.5556"`; JSON is a quoted string or `null`). `lagoon.Encrypted`: stdlib `crypto/hkdf` + AES-256-GCM, version prefix `0x10`, `Reveal` only accessor, `MarshalJSON`/`String`/`GoString` emit `[redacted]`. `TestEncryptedRoundTrip`, `TestEncryptedFreshNonce`, `TestEncryptedRedacts`, `TestEncryptedPreviousKeysFallback`, `TestEncryptedLoadAppKeyRejectsInvalid` PASS. Credential models type `api_key`/`token` as `lagoon.Encrypted` `json:"-"` + `Hidden()`. `OpenFromApp` (serve + migrate CLI) calls `loadEncryptionKeysFromApp`; empty `app.key` fails with `SUMMER_APP__KEY`. `DecryptLaravelPayload` is only referenced from `laravel_decrypt.go` and its test. |
| 5 | Paginated responses use the exact `{data, meta{current_page,last_page,per_page,total}}` envelope with no `links` key; another plugin extends a model's lifecycle through the GORM callback registry and a companion migration without editing the owning plugin's file; soft-deletable + uniquely-keyed tables pass a delete-then-recreate test. | ✓ VERIFIED | `lagoon.Page`/`PageMeta` JSON tags are exactly those four meta keys; `TestPaginateEnvelope` PASS (marshaled JSON has `data`/`meta`, no `links`; nil rows → `[]`). No production handler calls `Paginate` yet — this phase has no paginated HTTP routes (Phase 12). DATA-11: `parity/fixtureplugin` is constructed in-process, not `party.Register`ed, not imported from `app/app.go`. `Boot` registers `fixtureplugin:demo_album_created` after `gorm:create` gated on `models.Album`; own gormigrate adds `demo_extension_note`. `TestCrossPluginCallback` PASS (column exists, Album create increments counter, Genre does not, companion struct round-trips the note). `TestCollectionPublicTokenDeleteThenRecreate` PASS: soft-delete then reuse `public_token` → unique violation (plain UNIQUE, no partial index); hard-delete frees it. |
**Score:** 5/5 truths verified
Supporting plan truths that hold in code (not extra score rows): models-leaf probe closed (todo `done/`); Winter-directory plugins with `Models()`/`Migrations()` → `All()`; `lagoon.Fill` dropped-key logging; `WithSoftDeleteCascade` Collection→Album (`TestCollectionBeforeDeleteCascadesAlbums`, `TestWithSoftDeleteCascadeRollback`); `lagoon.Validate` unique respects `deleted_at`; `system_files` before plugin sets; Winter `ThumbFilename`/`PartitionDirectory`; blob via `gocloud.dev/blob` (memblob in tests, fileblob URL in `config/storage.yaml`); `key:generate`; organisations in the user plugin with no `widen_users`; notifications/wishlist tables have no `REFERENCES` on `user_id`/`collection_id`; security review maps every T-05-*.
### Required Artifacts
`gsd-sdk query verify.artifacts` reports missing files because PLAN paths are `summercms.go/lagoon/...` and `fonoteka.go/...` — those are not subdirectories of cwd. Verified against the two-repo layout (`lagoon/X` in cwd, `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/X` in the sibling).
| Artifact | Expected | Status | Details |
| -------- | ----------- | ------ | ------- |
| `lagoon/fill.go` | Fill + HasFillable/HasHidden | ✓ VERIFIED | 158 lines; allow-list then set; dropped keys log once off-prod |
| `lagoon/lifecycle.go` | Hook interfaces + WithSoftDeleteCascade | ✓ VERIFIED | 48 lines; BeforeValidate documented as not GORM-dispatched |
| `lagoon/paginate.go` | `{data, meta}` envelope | ✓ VERIFIED | 40 lines; no `links` field |
| `lagoon/relations.go` | RegisterJoinTable + no Association writes | ✓ VERIFIED | 28 lines; contract in comments |
| `lagoon/validate.go` | Laravel rule strings → 422 map | ✓ VERIFIED | 322 lines; `unique:` excludes `deleted_at IS NOT NULL` |
| `lagoon/jsonable.go` | Jsonable[T] Scanner/Valuer | ✓ VERIFIED | 97 lines; NULL vs `[]` via `Valid` |
| `lagoon/encrypted.go` | AES-256-GCM + Reveal + redaction | ✓ VERIFIED | 349 lines; stdlib hkdf |
| `lagoon/keygen.go` | `key:generate` | ✓ VERIFIED | 26 lines; appended in `RuntimeCommands` |
| `lagoon/laravel_decrypt.go` | Cutover-only CBC helper | ✓ VERIFIED | 105 lines; not called from Scan/Value |
| `lagoon/attach/file.go` | system_files File + Owner | ✓ VERIFIED | 141 lines; `AttachmentID string` |
| `lagoon/attach/migrations.go` | create_system_files | ✓ VERIFIED | 49 lines; Winter column set + lookup index |
| `lagoon/attach/thumb.go` | Thumb naming + lazy imaging | ✓ VERIFIED | 140 lines; `disintegration/imaging` v1.6.2 |
| `lagoon/attach/static.go` | StaticHandler | ✓ VERIFIED | 92 lines; not route-mounted (Phase 6, T-05-16) |
| `lagoon/attach/bucket.go` | OpenBucket fileblob/memblob | ✓ VERIFIED | 95 lines; empty URL fails; not called from `serve` yet |
| `fonoteka.go/.../models/registry.go` | Register/All | ✓ VERIFIED | Wired from `plugin.go` `Models()` |
| `fonoteka.go/.../updates/registry.go` | Register/All | ✓ VERIFIED | Wired from `plugin.go` `Migrations()` |
| `fonoteka.go/.../classes/album_write_service.go` | Fill boundary + syncArtists | ✓ VERIFIED | 92 lines |
| `fonoteka.go/.../classes/join_tables.go` | RegisterJoinTable hook | ✓ VERIFIED | 19 lines; `init` RegisterHook |
| `fonoteka.go/parity/testdata/php_schema_snapshot.sql` | PHP golden schema | ✓ VERIFIED | 2245-line pg_dump |
| `fonoteka.go/parity/schema_diff_test.go` | TestSchemaMatchesPHPSnapshot | ✓ VERIFIED | 416 lines; this session PASS |
| `fonoteka.go/parity/fixtureplugin/plugin.go` | DATA-11 test plugin | ✓ VERIFIED | 66 lines; not in `app/app.go` |
| `.planning/phases/05-data-layer-full-fidelity/05-SECURITY-REVIEW.md` | T-05-* mapping | ✓ VERIFIED | 103 lines; T-05-01..24 mapped |
### Key Link Verification
| From | To | Via | Status | Details |
| ---- | --- | --- | ------ | ------- |
| `fonoteka.go/.../fonoteka/plugin.go` | `models/registry.go` | `Models() return models.All()` | WIRED | `plugin.go:48-50` |
| `fonoteka.go/.../fonoteka/plugin.go` | `updates/registry.go` | `Migrations() return updates.All()` | WIRED | `plugin.go:46` |
| `classes/artist_resolver.go` | `classes/registry.go` | `init` `RegisterHook` | WIRED | callback is a models-leaf seam (body is a comment; writes go through `SaveAlbum.syncArtists`) |
| `classes/album_write_service.go` | `lagoon/relations.go` | `syncArtists` direct SQL | WIRED | no Association Mode |
| `classes/join_tables.go` | `lagoon/relations.go` | `lagoon.RegisterJoinTable` | WIRED | Album.Artists + Collection.Editors |
| `models/album.go` | `lagoon/validate.go` | `Album.Rules()` | WIRED | consumed in `SaveAlbum` → `lagoon.Validate` |
| `models/collection.go` | `lagoon/lifecycle.go` | `BeforeDelete` + `WithSoftDeleteCascade` | WIRED | GORM-native BeforeDelete |
| `models/album.go` | own `BeforeSave` | `refreshTrackTitles` + `stampMarketPrice` | WIRED | GORM-native BeforeSave |
| `lagoon/migrations.go` | `attach/migrations.go` | `Migrate()` runs attach first | WIRED | `migrator(..., "summercms.attach", attach.Migrations)` before plugin loop |
| `models/album.go` | `attach/file.go` | `MorphName()` PHP class string | WIRED | `` Golem15\Fonoteka\Models\Album `` |
| `models/user_ai_credential.go` | `lagoon/encrypted.go` | `APIKey lagoon.Encrypted` | WIRED | same for org AI + both Discogs tokens |
| `lagoon/encrypted.go` | `config/app.yaml` | `app.key` / `SUMMER_APP__KEY` | WIRED | `LoadAppKey` + `OpenFromApp` |
| `parity/fixtureplugin/plugin.go` | `models/album.go` | Boot GORM callback, no album.go edit | WIRED | `TestCrossPluginCallback` is the activator |
| `parity/schema_diff_test.go` | `php_schema_snapshot.sql` | load + information_schema diff | WIRED | this session PASS |
| `classes/album_write_service_fuzz_test.go` | `album_write_service.go` | `FuzzSaveAlbum` → `SaveAlbum` | WIRED | seed corpus PASS |
| `lagoon/hidden_marshal_test.go` | plugin `models.All()` | framework stays app-agnostic | WIRED | registry walk lives in `classes/hidden_marshal_test.go` (documented) |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
| -------- | ------------- | ------ | ------------------ | ------ |
| `SaveAlbum` | filled Album row | `lagoon.Fill` + `tx.Save` + `syncArtists` against Postgres | Yes — fuzz re-SELECTs `collection_id`/`market_price_source` | ✓ FLOWING |
| `MoneyString` | `market_price_stored` | `Album.BeforeSave`/`stampMarketPrice` then GORM numeric | Yes — `"12.55555"` persists `"12.5556"` | ✓ FLOWING |
| `Encrypted` | `api_key`/`token` | Scan/Value AES-GCM under published keys | Yes — fuzz `Reveal()` after reload | ✓ FLOWING |
| `File.Thumb` | public URL | memblob/fileblob + `ThumbFilename` | Yes — smoke test URL contains `thumb_<id>_200_200_0_0_crop.jpg` | ✓ FLOWING |
| `lagoon.Paginate` | `Page.Data`/`Meta` | caller-supplied rows | Helper only; no HTTP list handler this phase | ✓ FLOWING (library) |
| `TestSchemaMatchesPHPSnapshot` | information_schema | migrated Go DB vs loaded PHP dump | Yes — live Postgres both sides | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| -------- | ------- | ------ | ------ |
| lagoon unit (no PG) | `go test ./lagoon/ ./lagoon/attach/ -short -count=1` | `ok lagoon 4.767s`; `ok lagoon/attach 0.088s` | ✓ PASS |
| lagoon PG proofs | `go test ./lagoon/ ./lagoon/attach/ -run TestThumbFilename\|...\|TestEncrypted\|...` | `ok lagoon 10.772s`; `ok attach 10.607s` | ✓ PASS |
| fonoteka PG proofs | `go test ./plugins/.../classes/ ./parity/ -run TestAlbumArtists\|TestSchemaMatchesPHPSnapshot\|TestRollback...\|TestCrossPluginCallback\|...` | `ok classes 10.635s`; `ok parity 14.072s` | ✓ PASS |
| fuzz seed corpus | `go test ... -run FuzzSaveAlbum$|FuzzSaveCollection$|FuzzSaveUserAiCredential$` | `ok classes 6.981s` | ✓ PASS |
| Full 20s `-fuzz` campaign | not re-run (exceeds 10s spot-check cap) | seeds executed; no crashers in seed corpus | ? SKIP (seeds pass) |
### Probe Execution
No `scripts/*/tests/probe-*.sh` and no probe paths declared in PLAN/SUMMARY.
| Probe | Command | Result | Status |
| ----- | ------- | ------ | ------ |
| — | — | Step 7c SKIPPED (no declared probes) | SKIP |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ---------- | ----------- | ------ | -------- |
| DATA-03 | 05-01, 05-02 | timestamps, soft delete, lifecycle hooks, in-tx cascade | ✓ SATISFIED | GORM-native BeforeSave/BeforeDelete/AfterDelete; Collection cascade; BeforeValidate is a method tests call (not auto-dispatched — lifecycle.go documents this) |
| DATA-04 | 05-02 | belongsTo/hasOne/hasMany/belongsToMany + pivot business columns | ✓ SATISFIED | album_artists.sort_order + CollectionEditor columns round-trip; FKs for other relations |
| DATA-05 | 05-02 | rule strings → Laravel-shaped 422 map | ✓ SATISFIED | `lagoon.Validate`; `SaveAlbum` returns `FieldErrors`; unique respects soft-delete |
| DATA-06 | 05-01, 05-02, 05-06 | mass assignment via fillable; hidden + override | ✓ SATISFIED | service-level Fill this phase; HTTP request DTOs deferred to Phase 12 by SC 3 |
| DATA-07 | 05-02, 05-03, 05-06 | jsonable, money 4-decimal string, Encrypted | ✓ SATISFIED | Jsonable[T], MoneyString, Encrypted AES-GCM |
| DATA-08 | 05-04 | polymorphic attachments via gocloud blob | ✓ SATISFIED | system_files + File + Thumb + memblob/fileblob; StaticHandler unmounted (Phase 6) |
| DATA-09 | 05-02..05-06 | 25 models + squashed migrations matching PHP | ✓ SATISFIED | schema-diff PASS; 25 inventory models present |
| DATA-10 | 05-01 | pagination envelope, no links | ✓ SATISFIED | `Page`/`PageMeta` + `TestPaginateEnvelope` |
| DATA-11 | 05-05 | other plugin hooks lifecycle + companion migration | ✓ SATISFIED | fixtureplugin + `TestCrossPluginCallback` |
| CLI-03 | 05-05 | migrate up/status/rollback last of named plugin | ✓ SATISFIED | `TestRollbackIsolatesFonotekaFullSchema` on full schema |
No orphaned Phase 5 IDs in REQUIREMENTS.md. HTTP half of DATA-06 is explicitly Phase 12.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
| ---- | ---- | ------- | -------- | ------ |
| `lagoon/lifecycle.go` | 9–12 | `HasBeforeValidate` is not registered as a GORM callback | ℹ️ Info | Artist/Genre/Style slug defaulting is caller-invoked; GORM-native hooks (BeforeSave/Delete) do fire. Discretion in CONTEXT.md. |
| `classes/artist_resolver.go` | 34–43 | GORM callback body is a comment | ℹ️ Info | Intentional models-leaf seam; `SaveAlbum` calls `ResolveArtists` + `syncArtists` |
| `surf/serve.go` / `app/app.go` | — | `attach.OpenBucket` / `attach.Publish` never called at boot | ℹ️ Info | No HTTP upload/static route this phase (T-05-16). Helpers + tests exist. |
| `lagoon/paginate.go` | — | `Paginate` used only from its test | ℹ️ Info | No paginated HTTP handler this phase |
| `models/album.go` | — | No GORM fields for hasMany ratings / hasOne reservation / belongsTo collection | ℹ️ Info | FKs exist; Preload convenience deferred. Named SC 2 pivots are implemented. |
| `internal/build/artifact.go` | 177 | `TODO: describe` in make:* stub text | ℹ️ Info | Phase 4 scaffolding template, not Phase 5 debt |
| — | — | No `TBD`/`FIXME`/`XXX` in Phase 5 Go code | — | Debt-marker gate clean |
Independent security greps (excluding `.planning/`): `.Reveal()` only in `encrypted.go` definition + tests; `.Association(` only the forbid comment; production `AutoMigrate` zero (`TestNoAutoMigrate`, `TestPluginMigrationsDoNotUseAutoMigrate`).
### Human Verification Required
None. Schema equality, fill/encryption, thumbs, rollback isolation, and the fixture plugin are covered by testcontainers tests run this session.
### Gaps Summary
No blocking gaps. Phase goal is achieved in the codebase: 25 Płytarium models plus the Settings dedicated table and AlbumArtist pivot, squashed migrations whose final schema diffs equal the committed PHP snapshot (three documented allow-list entries), two-layer fillable fuzzed at service level, Encrypted AES-GCM with redaction, Winter-shaped attachments, pagination envelope, DATA-11 fixture plugin, and isolated `migrate:rollback --plugin`.
Non-blocking follow-ups already scheduled: HTTP DTO fuzz (Phase 12), StaticHandler `is_public` gate and bucket publish at serve (Phase 6), `widen_users` (Phase 7 AUTH-02).
---
_Verified: 2026-09-18T19:07:45Z_
_Verifier: Claude (gsd-verifier)_