Per C-02 and RESEARCH Summary, the album write service ports AlbumWriteService create/apply: AlbumFillFields only, ArtistResolver (numeric ids, Discogs ids and names resolved by name_key firstOrCreate, ordered pivot, displayFor artist_display), styles resolved or created by name/slug, `tracklist_text` parsed by the TracklistTextParser port (max 200 lines), `created_at` through the AddedDateParser port, barcode normalization, completeness sync, and cover import; unknown and server-owned keys never persist.
Per RESEARCH Pattern 4 and P11 D-08, POST albums and PUT albums/{id} run the whole write under `lighthouse.WithoutBroadcasting[models.Album]` and emit exactly one `created.fonoteka.album` or `updated.fonoteka.album` on the album's kind=collection channel with the full serialized album (genre, styles, artists, photos), and the created and updated broadcast goldens are assertions that pass (the gate scripts no longer expect them to skip).
Per D-02, POST albums/bulk validates `albums` required|array|min:1 plus every album rule under `albums.*.`, creates every row in one transaction under suppression, returns 201 `{"data":[...],"duplicates":[...]}`, and emits exactly one `collection.bulk_updated` `{"reason":"bulk_create","count":N}` only when at least one album was created in a kind=collection collection.
Per RESEARCH Pitfall 6, every album created in a kind=collection collection writes an `album_added` notification for each owner and editor except the actor, with notification:new and notification:count emitted in the write transaction (published after commit).
Per D-05, `cover_urls` (at most 5, https only, host discogs.com or a subdomain, fetchguard AllowHosts with the configured byte cap and timeout) are imported after the album row commits; each failed URL is recorded in cover_import_failures; a failed cover never fails or rolls back the album.
Per D-07 and D-11, POST albums/{id}/photos accepts exactly one of a multipart `file` (Laravel image/mimes/max:10240 plus the ImageContentGuard sniff-and-decode, webp included) or a `cover_url` fetched with fetchguard PublicOnly, maps each fetch failure reason to PHP's exact message, carries throttle:20,1 on the JWT route only, and answers 201 `{"data":PhotoDTO}`.
Per D-16, D-17 and D-19, albums/search uses the Typesense path only when the search_use_typesense gate is on, the engine is configured, the sort is not rating, name, artist or price, and no rating filter is set: one SearchPage with PHP's query_by order `name,artist_display,style_names,genre_name,track_titles,notes,label,catalog_number` and weights `10,10,5,5,3,1,3,3`, items re-gated in SQL by AlbumsAccessibleBy plus collection_id plus filters in engine order, and meta.total the re-gated count over at most 1000 engine ids; an engine error logs a warning and falls back to the SQL path, which uses ILIKE with % and _ escaped over the authenticated text fields and artists.name.
Per D-12, search replays are recorded and replayed with search disabled; Typesense behaviour is covered by Go tests with a fake engine (full leak suite in 12-05).
Per D-02, albums/stats, value, missing and sync (sync JWT only with throttle:60,1, PHP cursor `base64(<sync_version ISO>|<id>)`, per_page default 100 and max 200, tombstones in delta mode) return PHP's bodies for the active collection.
Per D-01 and D-10, the albums, rating, photos, stats, value, search, bulk routes and the lookups (GET artists, GET and POST styles, POST genres) are mounted once and reused on the token group exactly where routes.php mirrors them, each with exactly one inv.scope; the cover-price and Discogs match/apply/recognize/import routes stay pending (D-04).
Per D-08, the `nuxt-albums` flow (create with and without cover_urls, update, rate, unrate, photo upload and delete, search, stats, value, missing, sync, bulk, delete) is recorded from PHP and replays green, and expectedPortedRoutes reaches 99.
Edge (API-02 boundary): year accepts 1889 and 2100 and rejects 1888 and 2101; per_page is clamped to 1..50 for index and search and 1..200 for sync with PHP (int) prefix parsing; cover_urls accepts five URLs and rejects six; rating accepts 1 and 5 and rejects 0 and 6; search meta.total never exceeds 1000 on the Typesense path.
Edge (API-02 precision): market_price_stored round-trips as a fixed four-decimal string (never float64) with min:0 and max:999999.9999 compared exactly; albums/value totals are exact SQL decimal sums formatted like PHP sprintf('%.2f') with the rounding of tie values pinned against php -r output.
Edge (API-02 concurrency): two concurrent PUT albums/{id}/rating calls by the same user leave exactly one rating row (unique user and album) holding one of the two values; a bulk create interrupted by a failing row rolls back every row and emits nothing.
Search MUST NOT return or count an album the caller cannot access, whatever the search index contains
resolved
test
requirement_id
category
statement
status
verification
API-02
safety
A failed or slow cover download MUST NOT lose the album the user just saved
resolved
test
requirement_id
category
statement
status
verification
API-02
privacy
An album-added notification MUST NOT go to the actor or to anyone outside the collection's owner and editors
resolved
test
Phase Goal
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
This plan's slice: the Nuxt Albums UI and the MCP server can add, browse, edit, rate, photograph, bulk-add, sync, count, value and search albums and look up artists, styles and genres, with PHP's bodies and Centrifugo events (API-02; ROADMAP SC-2, SC-3 code path, SC-4).
Port the album write path and its helpers, the full album serializer, all album routes in scope, album search with the Scout-exact recount, the artists/styles/genres lookups, the Nuxt albums flow, and flip the created/updated broadcast goldens.
Purpose: albums are the bulk of the app; search is the security-critical read path (Pitfall 15). Decisions implemented: C-02, C-03, C-04, D-01, D-02, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-16, D-17, D-19, D-24 (consumer).
Output: classes, controllers, routes, recordings and the nuxt-albums flow, broadcast golden assertions, gate script updates.
Repos: fonoteka.go, plus summercms.go scripts/check-phase10.sh, scripts/check-phase10.1.sh and scripts/check-phase11.sh (their pending lists for the two goldens). Never add co-author tags.
Task 1: Adding an album from the Nuxt form or an MCP token stores it like PHP, notifies the household and publishes one created event
New write path beside SaveAlbum; SaveAlbum stays callable for existing tests and admin code.
Plan 12-03 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes WriteNotification` exits 0.
../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/style_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/added_date_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/duplicate_matcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (store, show, albumRules, duplicateAttrs, serializeDuplicate, context, embedsFor, publishAlbumBroadcast), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (create, apply, applyFields, applyAddedAt, syncArtists, resolveStyleIds, syncCompletion, maybeImportCovers), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ArtistResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/TracklistTextParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AddedDateParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumDuplicateMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (syncCompletion, missingTags), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsInputParser.php (normalizeBarcode), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyAlbumAdded), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (registerNotificationListeners, resolveNotificationActor), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (getBroadcastPayload, mediumFamily, beforeSave), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (serializeAlbum, serializeOwnRating, serializeArtist), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/style.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST__api_v1_fonoteka_albums_personal_token.yaml, scripts/check-phase11.sh (GOLDEN_SKIPS, refuse rule), scripts/check-phase10.sh and scripts/check-phase10.1.sh (APP_PENDING_SKIPS)
(1) Helpers (ports, each a small file): ArtistResolver (resolve: numeric id existing; `discogs:` or Discogs id inputs as PHP; a name trimmed and keyed by Artist name_key, firstOrCreate with slug; returns ordered ids without duplicates; `ArtistDisplayFor(ids)` joining names as PHP displayFor), `ResolveStyleIDs` (numeric existing id, else name or slug match, else create, as SerializesFonoteka resolveStyleIds), `ParseTracklistText` (TracklistTextParser: line split on CRLF/LF/CR, max 200 lines, autoNumber, position/title/artist/duration), `ParseAddedDate` (AddedDateParser FORMATS and MIN_DATE, returns nil for refused formats), `FindDuplicateAlbum(collectionID, attrs)` (AlbumDuplicateMatcher with its POLISH_FOLD map), `SyncCompletion` and `MissingTags` (AlbumCompletenessService BASE_TAGS, cond, price, rating rules), barcode normalization (normalizeBarcode). Keep every PHP constant and string verbatim.
(2) album_write_service.go: CreateAlbum and UpdateAlbum porting create/apply inside one lagoon.Transaction: Fill with AlbumFillFields, apply genre_id, styles, artists (pivot sort_order), tracklist or tracklist_text, created_at via ParseAddedDate, completion sync, dismiss_missing handling as applyFields does, collection_id server-set from the resolved active collection, market_price_source preserved as SaveAlbum does today. Strings are stored as sent (no trimming beyond what PHP does). Covers (cover_urls) are NOT imported in this task (Task 2 adds them after commit). SaveAlbum stays exported and green for its existing tests.
(3) Notifications (Pitfall 6): register a GORM after-create callback for models.Album in classes (RegisterHook pattern, Before gorm:commit_or_rollback_transaction as lighthouse/beachcomber do) that, for an album whose collection kind is collection, calls NotifyAlbumAdded(ctx, tx, svc, album, actor) writing album_added {album_id, album_name, collection_id, actor_name} to the owner and every editor except the actor (actor from the frontend principal in ctx, nil for none or a backend admin, per resolveNotificationActor); the wishlist branch is Phase 13. This fires for any album insert, as PHP's eloquent.created listener does.
(4) serialize.go: AlbumDTO ordered as serializeAlbum (id, name, artists ordered by pivot sort_order, artist_display, year, format, condition, medium, genre {id,name} or null, styles [{id,name}], shelf, barcode, discogs_id, edition, label, catalog_number, country, market_price_stored string or null, market_price_currency, market_price_checked_at, market_price_source, tracklist (never null), cover_import_failures (never null), notes, rating (the caller's own rating or null), photos (PhotoDTO list ordered as attachMany), created_at, updated_at); no reservation key (Phase 13). SerializeArtist, SerializeArtistAggregate, SerializeGenre, SerializeStyle. realtime.go: albumPayload uses the DTO (change albumBroadcast.Album to *AlbumDTO with omitempty) built from a fresh read with genre, styles, artists and photos.
(5) albums_controller.go: AlbumsStore (Resolve; ValidateRequest with albumRules(true) ported verbatim including the tracklist_text 200-line closure via CustomRule with PHP's literal message, the created_at date rules, cover_urls array|max:5 and cover_urls.* string|url|max:2048, tracklist.*.duration regex; FindDuplicateAlbum before create; CreateAlbum inside lighthouse.WithoutBroadcasting[models.Album]; then one svc.Emit of created.fonoteka.album on albumChannels with the albumPayload shape when the channel list is non-empty; respond 201 {"data":AlbumDTO,"duplicate":DuplicateDTO or null}), and AlbumsShow (AlbumsAccessibleBy plus collection_id = active id, embeds plus own rating, else 404 {"error":"Album not found"}). Routes: JWT and token POST /albums (token write) and GET /albums/{id} (token read) with [0-9]+.
(6) Goldens (D-09): rework TestBroadcastGoldens created to drive POST albums through the assembled handler on the memory/fake Centrifugo driver and assert the recorded created golden (timestamps and actor normalized by tide); remove its pending skip. Clear TestBroadcastGoldens/created from APP_PENDING_SKIPS in scripts/check-phase10.sh and scripts/check-phase10.1.sh and from GOLDEN_SKIPS in scripts/check-phase11.sh (keep updated until Task 2), updating their comments, and make each script's --self-test still pass.
(7) Recordings: POST albums 201 (minimal create, create with styles and tracklist_text, create that triggers duplicate), 422 cases (missing name and artists, year 1888, six cover_urls, bad duration, numeric name), token 201; GET albums/{id} 200 own, 200 shared to bob, 404 foreign, token pinned 404 for an album outside the pin. Flip the four routes to ported and raise expectedPortedRoutes by 4.
(8) Smoke tests: TestAlbumStoreSingleCreatedEvent (one publication, payload album has artists in order, no duplicate events), TestAlbumAddedNotifiesHousehold (bob gets the notification when alice creates; alice does not), TestAlbumWriteHelpersMatchPHP (table cases for the parsers and the duplicate matcher taken from the PHP sources' behaviour; run php -r to confirm any ambiguous case before pinning it).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumStoreSingleCreatedEvent|TestAlbumAddedNotifiesHousehold|TestAlbumWriteHelpersMatchPHP)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase11.sh --self-test
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "DATA RACE", or lacks "--- PASS: TestBroadcastGoldens/created" or "--- PASS: TestParityCorpus/coverage"; "--- SKIP: TestBroadcastGoldens/created" appears; the gate self-test reports a failed detector.</fails_when>
<acceptance_criteria>
- grep -c 'WithoutBroadcasting\[models.Album\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go prints at least 1.
- grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh no longer lists TestBroadcastGoldens/created.
- grep -c 'album_added' ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go prints at least 1.
- The PHP tracklist_text closure message appears verbatim in albums_controller.go (grep -c 'more than 200 lines' ... prints at least 1).
- go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes -run 'TestSaveAlbum|FuzzSaveAlbum' -count=1 still passes (old callers intact).
</acceptance_criteria>
A created album matches PHP's response, DB rows, household notifications and single Centrifugo event, on both auth groups, and the created golden is an assertion.
Task 2: Users edit, rate, photograph, bulk-add, delete, sync, count and value their albums with PHP's bodies and events
Handlers and helpers are additive; the cover import runs after commit and can be disabled by config max_covers.
../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_sync.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_stats.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_bulk_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_stats_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_sync_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/ratings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/files/, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/README.md, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (index, update, destroy, bulk, stats, value, missing, uploadPhoto, coverUrlErrorMessage, deletePhoto, paginated), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/RatingApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumSyncController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSyncService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (applyMissingConstraints, applyListFilters, counters), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ImageContentGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/CoverImporter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php, summercms.go modules/fetchguard/fetch.go and policy.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (12-02 upload pattern), ../fonoteka.go/parity/fixtures/broadcasts/updated.yaml, ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_albums_stats_jwt.yaml, GET___fonoteka_api_v1_albums_value_jwt.yaml, GET___fonoteka_api_v1_albums_missing_jwt.yaml, GET___fonoteka_api_v1_albums_sync_jwt.yaml, POST___fonoteka_api_v1_albums_bulk_jwt.yaml (all under ../fonoteka.go/parity/fixtures/routes/)
(1) Covers (D-05, D-07, T-12-09): `CoverImporter` ports CoverImporter.import with a `Fetch` field defaulting to fetchguard.Fetch with AllowHostsMode, AllowHosts ["discogs.com"] (PHP suffix `.discogs.com`), MaxBytes and Timeout from the new config keys; https only; at most `discogs.max_covers`; each URL that fails (scheme, host, fetch error, not an image per IsAllowedImage) goes into cover_import_failures in input order; success attaches a photo. Import runs AFTER the album write commits (never hold the write tx across downloads), then saves cover_import_failures, then serializes and emits; this applies to POST albums (extend Task 1's store), PUT albums/{id} and each bulk row. `IsAllowedImage` ports ImageContentGuard (sniff ALLOWED_IMAGE_MIMES, decode config succeeds, webp included per D-24). `FetchManualCover` uses fetchguard PublicOnlyMode with `covers.manual_url_*` limits and adds the app-level checks PHP does (2xx status, image content type, IsAllowedImage), returning a reason; `ManualCoverMessage` maps invalid_url, scheme, unresolvable, private_ip, network_error, http_status, content_type, too_large, invalid_image to PHP's exact English strings.
(2) Album routes: AlbumsIndex (per_page phpInt default 20 clamped 1..50, page min 1, AlbumsAccessibleBy plus active collection, order by name then id, lagoon pagination envelope without links), AlbumsUpdate (albumRules(false), UpdateAlbum under WithoutBroadcasting, one updated.fonoteka.album Emit, cover_urls after commit, 200 {"data":AlbumDTO}), AlbumsDestroy (soft delete through the model so the automatic deleted broadcast and index removal fire; PHP response body), ratings (PUT albums/{id}/rating validates rating required|integer|min:1|max:5, upserts the caller's row on the unique (user, album) key, returns {"data":{"rating":n}}; DELETE returns {"data":{"rating":null}}; 404 {"error":"Album not found"}), photos (POST albums/{id}/photos: exactly one of file or cover_url else 422 {"error":"Validation failed","errors":{"file":["Provide exactly one of file or cover_url."]}}; file branch: ValidateRequest file rules, then IsAllowedImage else The file is not a valid image., then attach as 12-02 does; cover_url branch: cover_url required|string|url|max:2048 then FetchManualCover, failure 422 under cover_url; JWT route throttle:20,1, token route none; DELETE albums/{id}/photos/{fileId} with Photo not found), AlbumsBulk (rules prefixed albums.*., one lagoon.Transaction under WithoutBroadcasting creating each row via CreateAlbum with duplicates computed first, rollback on any failure, then cover imports per row after commit, then one collection.bulk_updated Emit only when created rows exist and the active kind is collection; 201 {"data":[...],"duplicates":[...]}). Stats (AlbumStats: totals albums, all_count, vinyl, cd, cassette, box, artists, missing_albums and genres aggregates with album_count ordered as PHP plus an id tiebreak), value (AlbumValue: per-currency SUM in SQL as decimal, PHP sprintf('%.2f') formatting confirmed with php -r for tie values, albums_total, albums_valued, albums_without_price), missing (filters page, per_page 1..50, include_condition, include_price, include_rating, dismissed as laravelBoolean; MissingTags per album and the counters), sync (AlbumSyncDelta and AlbumTombstones ports with the cursor, checkpoint clamped to now, mode, collection_key (CollectionKey), total_estimate, changed rows carrying sync_version; JWT only with throttle:60,1). Token twins exactly as routes.php (search later, stats read, value read, bulk write, index read, update write, destroy write, photos POST and DELETE write, rating PUT and DELETE write); every {id} and {fileId} gets its Where right after its route.
(3) Goldens: drive PUT albums/{id} through the handler in TestBroadcastGoldens updated, drop its skip and its remaining entries in the three gate scripts (now empty pending lists; self-tests pass); keep deleted and bulk green through the real handlers.
(4) Config keys in plugin config.yaml and ../fonoteka.go/README.md: discogs.max_covers, cover_max_bytes, cover_timeout_seconds, cover_host_suffix; covers.manual_url_max_bytes, manual_url_timeout_seconds (PHP defaults).
(5) Recordings: index (two pages, per_page=abc clamps), update 200 and 422 and 404, destroy 200 and 404, rating PUT 200, 422 (0 and 6) and DELETE 200, photos multipart 201 (png and webp), 422 both-or-neither, 422 non-image, cover_url 422 for http:// (scheme) and a private literal address (private_ip) without network, photo delete 200 and 404, bulk 201 with two rows, 422 {"albums":[]} (one message), cover_urls with a non-https and an off-list host producing cover_import_failures (network-free, Pitfall 10), stats, value, missing (with each include flag), sync full and delta with a cursor; token twins each with their own case. Flip every route of this task and raise expectedPortedRoutes.
(6) Smoke tests: TestCoverImportAfterCommit (injected Fetch: one success, one failure recorded; a fetch panic or timeout leaves the album committed), TestManualCoverReasons (every reason maps to PHP's text; private IPv4, IPv6 loopback and a NAT64-embedded private address refused by fetchguard), TestAlbumPhotoUpload (row, blob, thumb, webp accepted, polyglot rejected, 10240 KB plus one byte rejected), TestBulkSingleSummaryEvent (rollback emits nothing), TestRatingUpsertConcurrent, TestAlbumValueFormatting (php -r pinned values).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCoverImportAfterCommit|TestManualCoverReasons|TestAlbumPhotoUpload|TestBulkSingleSummaryEvent|TestRatingUpsertConcurrent|TestAlbumValueFormatting)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase10.sh --self-test && scripts/check-phase10.1.sh --self-test && scripts/check-phase11.sh --self-test
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestBroadcastGoldens/updated, TestBroadcastGoldens/bulk, TestCoverImportAfterCommit and "--- PASS: TestParityCorpus/coverage"; a gate self-test reports a failed detector.</fails_when>
<acceptance_criteria>
- grep -c 'PublicOnly' ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go and grep -c 'AllowHosts' ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go each print at least 1.
- grep -c 'Provide exactly one of file or cover_url.' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go prints 1.
- grep -n 'throttle:20,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go matches only the JWT photos route and grep -n 'throttle:60,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go only the sync route.
- grep -n 'APP_PENDING_SKIPS=' scripts/check-phase10.sh scripts/check-phase10.1.sh and grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh show empty lists.
- The upload fixtures carry parts: with sha256 and their url/thumb_url bodies replay through the tide upload mask.
- grep -c 'cover_max_bytes' ../fonoteka.go/README.md prints at least 1.
</acceptance_criteria>
The whole album management surface the Albums UI uses is ported with PHP's bodies, single-event broadcasting, guarded cover fetching and exact upload handling, and all four album broadcast goldens are assertions.
Task 3: Users search their albums and look up artists, styles and genres, and the Nuxt albums journey replays end to end
../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/lookups_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (search rules, RATING_FILTERS, SORTS), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (paginate, getTotalCount lines 533-556), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Engines/TypesenseEngine.php (maxPerPage, performPaginatedSearch), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Searchable.php (queryScoutModelsByIds), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ArtistApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/StyleApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php (store), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/PolishOrder.php, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useAlbumsQuery.ts, useBulkAlbumTape.ts, useRealtimeSync.ts (request order for the flow), ../fonoteka.go/plugins/golem15/fonoteka/search.go (settingsGate), ../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (aggregate and Polish ordering precedent), ../fonoteka.go/parity/fonoteka_flows_test.go (12-03 flow test), ../fonoteka.go/parity/genres_seed_test.go and ../fonoteka.go/parity/README.md (the temporary genres seed hook note)
(1) album_search.go `SearchAlbums` (D-16, D-17, D-19): validation as PHP search (rating in RATING_FILTERS, sort in SORTS, dir asc|desc, format in AlbumFormats, medium in mediums, artist nullable|integer|exists:golem15_fonoteka_artists,id, decade nullable|integer|between:1880,2100 plus the multiple-of-ten CustomRule with PHP's literal message, genre and style nullable|integer). Typesense path conditions as resolveSort/searchWithScout: settingsGate on, engine Configured, q non-empty or as PHP requires, sort not in rating/name/artist/price and no rating filter. Query: Q, QueryBy `name, artist_display, style_names, genre_name, track_titles, notes, label, catalog_number` (PHP order, not models.AlbumSearchQueryBy), QueryByWeights 10,10,5,5,3,1,3,3, FilterBy `collection_id:=[]` joined with genre/style/format/medium/artist/decade filters by ` && ` exactly as PHP, SortBy `_text_match:desc,` prefix when q is set then `year` or `created_at` with dir, Page and PerPage. Items: SQL `id IN ids` plus AlbumsAccessibleBy(user, token) plus collection_id = active plus the same filters plus embeds, ordered by engine position. Total (Scout v10.25.0): when len(page ids) is below found, fetch min(found, 1000) ids with SearchPage at per_page found (if under 250) or pages of 250 up to 1000, then COUNT the re-gated rows; else count the re-gated page ids; last_page = max(ceil(total/per_page), 1). Engine error: slog warning without query text, then the SQL path. SQL path: text search ILIKE with `%`, `_` and `\` escaped (ESCAPE '\') over name, notes, artist_display, track_titles, label, catalog_number OR EXISTS artists.name, filters, rating filter joined to the caller's rating, sorts with pl-x-icu collation for name and artist, rating and price sorts as PHP, id tiebreak always, lagoon pagination envelope.
(2) album_search_controller.go AlbumsSearch on JWT and token (read). Replays use search disabled (D-12): the parity config keeps search_use_typesense off.
(3) lookups_controller.go: ArtistsIndex (aggregate with album_count over the active collection's accessible albums, PolishOrder by artists.name with pl-x-icu and id tiebreak, PHP validation if any), StylesIndex (orderBy name with collation decision documented and id tiebreak), StylesStore and GenresStore (PHP validation and bodies, including the existing-name branch). Mount JWT and token twins (index read, store write). The temporary genres seed hook stays: it now seeds identities for ported routes; update the parity README sentence that promised to delete it once POST genres is ported, explaining why it stays.
(4) Flow (D-08): record nuxt-albums from PHP with a request spec in the composables' order (create without and with cover_urls using a network-free failing URL, update, rate, unrate, photo upload multipart and delete, search with q and filters, stats, value, missing, sync full then delta, bulk, delete) and add subtest nuxt-albums to TestFonotekaNuxtFlows replaying it against Go on the fonoteka seed.
(5) Recordings: search (q match, no match, each filter, each sort, rating filter, per_page clamp, 422 for decade 1995, unknown artist id, bad sort), token search pinned; artists, styles GET (jwt and token), styles POST 201/200-existing/422, genres POST 201/existing/422 (jwt and token). Flip all remaining Phase 12 routes; expectedPortedRoutes becomes 99. Confirm with check_corpus that no Phase 12 route of CONTEXT's domain list is still pending and that the D-04 Discogs routes and every wishlist/public/onboarding route are still pending.
(6) Smoke tests: TestAlbumSearchSQLEscaping (q 50% and a_b match literally), TestAlbumSearchTypesenseRecount (fake engine registered through beachcomber.RegisterEngine with scripted ids and found: a stale id shortens the page and is not counted; found 3000 caps the recount at 1000; an engine error falls back to SQL with a warning).
go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumSearchSQLEscaping|TestAlbumSearchTypesenseRecount)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestParityCorpus|TestBroadcastGoldens)$' -count=1 -v && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-albums", "--- PASS: TestFonotekaNuxtFlows/nuxt-collections" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or an unrecorded route.</fails_when>
<acceptance_criteria>
- grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows the value 99.
- grep -c '10,10,5,5,3,1,3,3' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go prints at least 1 (or the weights appear as the equivalent int slice with a comment naming PHP's string).
- grep -c '1000' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go prints at least 1 (Scout max_total_results cap).
- grep -c "ESCAPE" ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go prints at least 1.
- The manifest still marks POST /api/v1/fonoteka/albums/{id}/cover-price/discogs personal_token, POST /_fonoteka/api/v1/albums/recognize jwt and every wishlist/ route as pending.
- test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml succeeds.
</acceptance_criteria>
Search, lookups and the full albums journey match PHP; every Phase 12 route is ported and the corpus stands at 99 ported routes.
<threat_model>
Trust Boundaries
Boundary
Description
Caller → album ids in paths and bulk bodies
Ids and bodies are untrusted; tenancy is server-resolved
User-supplied URLs → outbound fetch
cover_urls and cover_url make the server fetch remote content
Uploaded bytes → blob storage and decoders
Photos are stored and thumbnailed
Search engine → SQL
Engine ids and found counts are candidates only
Write transaction → Centrifugo and notifications
Album data and notifications leave for subscribers
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-12-33
Information Disclosure
albums/{id}, photos/{fileId}, rating
high
mitigate
AlbumsAccessibleBy plus collection_id = active on every lookup; photo lookup scoped to the album's attachments; one 404 body for foreign and missing (Tasks 1-2).
T-12-02
Information Disclosure
albums/search items and meta.total
high
mitigate
Every engine id re-gated in SQL; total recounted in SQL over at most 1000 ids (D-19); engine errors fall back to SQL (Task 3; leak suite in 12-05).
T-12-34
Elevation of Privilege
token pin on albums, stats, value, search, lookups
high
mitigate
Resolve honours the pin; AlbumsAccessibleBy narrows; sync is JWT only (Tasks 1-3).
T-12-09
Tampering / Information Disclosure
cover_urls and cover_url fetches (SSRF)
high
mitigate
fetchguard AllowHosts discogs.com for imports and PublicOnly for manual URLs: dial-time private-address refusal, no redirects, byte cap, timeout; https only; never inside the write tx (Task 2).
T-12-10
Tampering / Denial of Service
photo upload
high
mitigate
body limit, max:10240 rule, ImageContentGuard sniff and decode, throttle:20,1 on JWT (Task 2).
T-12-11
Tampering
mass assignment on albums and bulk
high
mitigate
AlbumFillFields only; collection_id and market_price_source server-controlled; fuzz in 12-05 (Tasks 1-2).
T-12-23
Information Disclosure
album_added notifications and broadcasts
medium
mitigate
Recipients are the collection's owner and editors minus the actor; broadcasts only on the kind=collection channel; published after commit (Task 1).
T-12-24
Denial of Service
search recount and bulk size
medium
mitigate
Recount capped at 1000 ids in pages of 250; bulk bounded by body limits and validation; cover imports capped at max_covers per album (Tasks 2-3).
T-12-SC
Tampering
package installs
low
accept
No new dependency in this plan (golang.org/x/image arrived in 12-01).
</threat_model>
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; `go vet ./... && go test ./...` green in summercms.go (gate scripts only changed there).
- Parity corpus at 99 ported and passing; TestFonotekaNuxtFlows (both flows) and TestBroadcastGoldens (all four) pass; check_corpus --require-recorded --check-secrets green.
- `scripts/check-phase10.sh --self-test`, `scripts/check-phase10.1.sh --self-test`, `scripts/check-phase11.sh --self-test` pass.
<success_criteria>
All 36 album and lookup routes in Phase 12 scope are ported on the right groups with PHP bodies.
One broadcast per store/update, one summary per bulk, household notifications on create, covers imported after commit through fetchguard.
Search re-gates items and total; the Nuxt albums journey replays green.
</success_criteria>
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-04-SUMMARY.md` when done.