Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-PLAN.md
Jakub Zych 9c87a59532 docs(13): create phase plan
Six sequential plans: framework gaps, notifications/credentials/onboarding, wishlist, CSV, public views, unit tests and gate. Research open questions marked resolved per the plan-count checkpoint.
2026-10-02 20:12:35 +02:00

52 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 03 execute 3
13-02
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go
../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go
../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go
../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go
../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_subscriptions_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_reservations_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_peer_controller.go
../fonoteka.go/plugins/golem15/fonoteka/jobs.go
../fonoteka.go/plugins/golem15/fonoteka/mail.go
../fonoteka.go/plugins/golem15/fonoteka/realtime.go
../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm
../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go
../fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go
../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/fixtures/routes/
../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml
../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json
../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml
../fonoteka.go/parity/fixtures/broadcasts/wishlist-item-added.yaml
../fonoteka.go/parity/fixtures/broadcasts/wishlist-purchased.yaml
../fonoteka.go/parity/fixtures/broadcasts/reservation-revealed.yaml
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/fonoteka_flows_test.go
../fonoteka.go/parity/broadcast_goldens_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/README.md
true
API-03
tokens raw_tokens tasks confidence
340000 340000 4 low
truths artifacts key_links prohibitions
`GET wishlist/albums` on both groups (token group with `inv.scope:read`) lists the caller's own wishlist (provisioned once as `Moja lista życzeń` under a users-row lock when missing) with PHP's pagination envelope and, per reservation, the `reservation` key masked for the owner (`reserved_by` omitted before reveal); `GET wishlist/albums/{id}` (JWT only) shows one item the same way and answers PHP's 404 JSON for a foreign or missing id.
`POST wishlist/albums` (both groups, 201 with `data` and `duplicate`), `PUT` and `DELETE wishlist/albums/{id}` (both groups) go through the Phase 12 album write path into the own wishlist; `condition` and `shelf` are `prohibited` and a Validator::make failure answers 422 `{"error":"Validation failed","errors":...}`; `GET wishlist/albums/similar` answers `{"wishlist":[...],"collection":[...]}` from a case-insensitive escaped name/artist match (limit 6, by name).
Per D-08 and the planner's discretion, the item-added branch extends the existing `albumAddedCallback`: every insert of an album into a wishlist (JWT store, token-group store, any bulk or later CSV path) runs it exactly once on the insert's transaction; each subscriber except the actor gets a `wishlist_item_added` bell row `{album_id, album_name, collection_id, owner_name}` when ws_enabled, and for email_enabled subscribers the `golem15_fonoteka_wishlist_digest_queue` row is upserted atomically (`ON CONFLICT (user_id, collection_id)`, item_count + 1).
Per D-08 and the 13-01 job contract, only the insert that creates a digest-queue row dispatches `WishlistDigestArgs{SubscriberID, WishlistCollectionID}` on `fonoteka.wishlist.digest` with label `wishlist_digest` and a 1800 s delay through conga.Dispatch on the same transaction; later inserts in the window only bump item_count; no digest worker or mail exists until Phase 14 (D-04).
`GET|PUT wishlist/share`, `POST wishlist/share/regenerate` (`throttle:10,1`, token rotated under a row lock), `GET wishlist/household` (household wishlists with 8-album previews by name), `GET|PUT wishlist/settings` (`reservations_allowed required|boolean`, 422 JSON on failure) and `GET wishlist/subscriptions` answer PHP's bodies, JWT group only.
Subscriptions by token (`wishlist/token/{token}/subscribe` GET/POST 201/DELETE) and by collection (`wishlist/{collectionId}/subscribe` GET/POST 201/DELETE, `[0-9]+`) and `GET wishlist/subscribers` follow WishlistSubscriptionService: subscribe is an upsert stamping subscribed_at, household peers appear as synthetic subscribers with both channels on and a `forced` state, a collection subscription needs a wishlist visible to the caller, and an absent subscription or invisible wishlist answers the Winter 404 page.
Reservations: `POST wishlist/albums/{id}/reserve` answers 201 for a reservable album, 422 `cannot_reserve_own_wishlist` on the caller's own wishlist and 409 `reservations_disabled` when the wishlist disallows it; a second reserver loses under the album row lock and unique album_id; `DELETE .../reserve` works only for the reserver (Winter 404 page otherwise); `POST .../reveal` is owner-only, one-way and idempotent, writes only a `reservation_revealed` bell row and answers `{"data":{"reserved":false}}` when nothing is reserved.
Per D-07, `POST wishlist/albums/{id}/purchase` moves the album to the caller's active collection by updating collection_id, releases its reservation, writes `wishlist_item_purchased` bell rows to every ws-enabled subscriber (the actor included) and to the reserver if not already notified, and enqueues one `golem15.fonoteka.wishlist_purchased_mail` job per email-enabled subscriber on `mail` in the same transaction; mail leaves only after commit and a rolled-back purchase leaves no row, job or mail; the response body and DB state equal PHP's.
The purchase mail worker sends `golem15.fonoteka::mail.wishlist_item_purchased` (or `-en` for a subscriber whose preferred_locale is en) with vars albumName and wishlistName, PHP's subject and the `plytarium` layout, to the subscriber's address, observed through postcard's memory driver; it skips a subscriber that no longer exists and never logs the args.
`GET wishlist/{collectionId}/albums` and `GET wishlist/{collectionId}/albums/{albumId}` (JWT, both `[0-9]+`) show a visible peer's wishlist through the reservable-wishlist scope with the viewer's reservation state; a foreign, invisible or missing wishlist answers the Winter 404 page; none of reserve, reveal, purchase, similar, share, settings, subscriptions or peer routes is mounted on the token group.
Through the assembled router the four overlap pairs from 13-01 dispatch to the real handlers, and the 30 wishlist routes are ported with re-recorded fixtures; `expectedPortedRoutes` is 143 and `wishlist/albums/{id}/match` and `apply-release` stay pending (D-01).
Per D-12 and D-13, `TestFonotekaNuxtFlows/nuxt-wishlist` (recorded with QUEUE_CONNECTION=database: create, share, subscribe by token and by collection, reserve as a second user, reveal, purchase, settings, household, peer albums, with GET notifications steps as each recipient) and `TestFonotekaNuxtFlows/mcp-wishlist` replay green, the recorded digest-queue rows equal Go's, and the `notification:new`/`notification:count` publications match their goldens.
Edge (API-03 concurrency): two users reserving one album at once produce exactly one reservation; the other gets the 409 PHP answers.
Edge (API-03 adjacency): three wishlist items added within one window by the owner produce one digest-queue row with item_count 3 and exactly one dispatched digest job per email-enabled subscriber; the owner (actor) gets neither a bell row nor a digest row.
Edge (API-03 ordering): household wishlists and subscriptions list in PHP's order (household by owner name, then other subscriptions); previews hold at most 8 albums ordered by name.
Edge (API-03 encoding): `similar` treats `%`, `_` and `` in the query literally and matches case-insensitively, as MariaDB `_ci` does in production.
Edge (API-03 empty): a user without a wishlist gets one provisioned on first read and an empty `data` list; `reveal` with no reservation answers `{"data":{"reserved":false}}`.
statement verification
Edge (API-03 concurrency): concurrent first reads by one user provision exactly one wishlist. backstop
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go ActiveWishlist, ProvisionWishlist, WishlistsVisibleTo, ReservableWishlistIDs Moja lista życzeń
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go ReserveAlbum, CancelReservation, RevealReservation, ReleaseForAlbum, ReservationStateForViewer, ReservationDTO FOR UPDATE
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go NotifyWishlistItemAdded, NotifyWishlistItemPurchased, NotifyReservationRevealed, EnqueueWishlistDigest, JobDispatcher, SetJobDispatcher ON CONFLICT
path provides
../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm Polish purchase mail template
path provides
../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml recorded Nuxt wishlist journey
path provides
../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json recorded digest-queue rows (D-13)
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go albumAddedCallback's wishlist branch calls NotifyWishlistItemAdded on the insert transaction NotifyWishlistItemAdded
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go Dispatch WishlistDigestArgs with WishlistDigestJobQueue, WishlistDigestLabel, WishlistDigestDelay; Enqueue WishlistPurchasedMailArgs WishlistDigestLabel
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/jobs.go summercms.go modules/postcard purchase mail worker sends the locale-picked template wishlist_item_purchased
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go summercms.go modules/surf/overlap.go the four overlapping wishlist pairs register as one family each wishlist/{collectionId}
requirement_id category statement status verification
API-03 privacy A wishlist owner MUST NOT learn who reserved an item before revealing it, through any response, notification or publication resolved test
requirement_id category statement status verification
API-03 transparency No digest job may be worked, completed or faked in Phase 13, and no mail may be sent from a rolled-back purchase resolved test
requirement_id category statement status verification
API-03 transparency The wishlist match and apply-release routes MUST NOT be mounted in any form; they stay pending for Phase 14 (D-01, C-07) resolved test

Phase Goal

ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets.

This plan's slice: a household keeps wishlists: the owner adds, edits and shares items; others follow by link or as household members, get bell notifications, reserve gifts secretly, and the owner marks items bought, exactly as the Nuxt app and fonoteka-mcp see PHP (API-03; ROADMAP SC-1).

Port the 30 Phase 13 wishlist routes (26 JWT, 4 token group) with the wishlist resolver, visibility scopes, subscription and reservation services, the reservation mask, the similarity finder, share/settings/household, purchase with its mail job, and the item-added notifications with the digest queue; record the nuxt-wishlist and mcp-wishlist flows, the publication goldens and the digest rows.

Purpose: the wishlist is Płytarium's second user-facing surface and the main source of notifications. Decisions implemented: D-01 (match/apply-release stay pending), D-07, D-08, D-12, D-13, C-01, C-02, C-03, C-04, C-05, C-07; the item-added discretion is resolved by extending albumAddedCallback (RESEARCH recommendation). Output: classes, handlers, mail job and templates, routes, recordings, flows, goldens; ported count 143.

Repo: fonoteka.go only. Commits path-scoped; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-PATTERNS.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md @../fonoteka.go/plugins/golem15/fonoteka/routes.go @../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go - From 13-01: surf overlap families (no API change); conga unregistered-kind dispatch; `lagoon` `prohibited`; tide publication masks; job contract `classes.WishlistDigestKind`, `WishlistDigestJobQueue`, `WishlistDigestLabel`, `WishlistDigestDelay`, `WishlistDigestArgs`, `WishlistPurchasedMailKind`, `WishlistPurchasedMailQueue`, `WishlistPurchasedMailAttempts`, `WishlistPurchasedMailArgs`; `php_parity.sh rows`; capture `share:wishlist`; `QUEUE_CONNECTION` override. - From 13-02: `classes.WriteNotification` usage for new types, `NotificationsIndex` (for flow steps), seed-state pattern. - Existing classes: `WriteNotification(ctx, tx, svc, userID, typ, payload)` with `NotificationPayload` ordered pairs, `NotificationTypeWishlistItemAdded`, `NotificationTypeWishlistItemPurchased`, `NotificationTypeReservationRevealed`, `albumAddedCallback` (returns early for non-real collections), `realtimeService` atomic pointer and `SetRealtimeService`, `NotificationActor`, `JobQueue` interface (Enqueue) in invitation_service.go, `CreateAlbum`, `UpdateAlbum`, `FindDuplicateAlbum`, `SerializeDuplicate`, `SerializeAlbum`/`AlbumDTO`, `AlbumsAccessibleBy`, `AccessibleBy` (owner's wishlist exempt from a token pin), `ShareStateFor`, `EnableShare`, `DisableShare`, `RegenerateShare`, `RenameShare`, `GenerateShareToken`, `Resolve`, `ProvisionCollection`, `WithoutBroadcasting[models.Album]` plus `Service.Emit` for album writes (Phase 11 note). - Models: `Collection{Kind, OwnerID, PublicToken, PublicEnabled, ReservationsAllowed?}`, `AlbumReservation` (UNIQUE album_id), `WishlistSubscription` (UNIQUE user_id+collection_id, ws_enabled, email_enabled, subscribed_at), `WishlistDigestQueue` (UNIQUE user_id+collection_id, item_count). - conga: `(*Manager).Dispatch(ctx, db, args, DispatchOpts{Label, Count, Metadata, Queue, Delay})`, `(*Manager).Enqueue(ctx, db, args, EnqueueOpts{Queue, MaxAttempts})`, `conga.Job(fn, conga.OnQueue(q), conga.MaxAttempts(n))`. - postcard: `Mailer.Send(ctx, postcard.Message{Template, To, Vars})`, memory driver; plugin templates via `MailTemplates()`/`MailTemplatesFS()` (mail.go). - PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/WishlistAlbumApiController.php, WishlistAlbumReservationController.php, WishlistHouseholdController.php, WishlistPeerAlbumController.php, WishlistSettingsController.php, WishlistShareController.php, WishlistSubscriptionController.php, classes/ActiveWishlistResolver.php, WishlistProvisioner.php, WishlistSubscriptionService.php, AlbumReservationService.php, AlbumSimilarityFinder.php, AlbumWriteService.php (moveToCollection, lines 270-282), CollectionShareService.php, NotificationService.php (lines 95-284), models/Collection.php (wishlistsVisibleTo, lines 247-264), models/WishlistDigestQueue.php, models/AlbumReservation.php, traits/SerializesFonoteka.php (lines 55-140), views/mail/wishlist_item_purchased.htm and -en.htm, Plugin.php (lines 80-112), routes.php (lines 130-225, 501-510)}; fonoteka-mcp /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 295-307); Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ and stores/fonoteka.ts (wishlist calls).

Artifacts this phase produces

(This plan's share.)

  • classes: ActiveWishlist, ProvisionWishlist, WishlistsVisibleTo, ReservableWishlistIDs, Subscribe, Unsubscribe, SubscribersOf, FollowerCountOf, SubscriptionStateFor, SubscriptionsFor, ReserveAlbum, CancelReservation, RevealReservation, ReleaseForAlbum, ReservationStateForViewer, ReservationDTO, ReservationContext, FindSimilarAlbums, MoveToCollection, NotifyWishlistItemAdded, NotifyWishlistItemPurchased, NotifyReservationRevealed, EnqueueWishlistDigest, JobDispatcher (Enqueue and Dispatch), SetJobDispatcher, ErrNoJobDispatcher, ErrCannotReserveOwnWishlist, ErrReservationsDisabled, ErrAlreadyReserved; AlbumDTO.Reservation *ReservationDTO (json:"reservation,omitempty").
  • controllers/api: WishlistAlbumsIndex, WishlistAlbumsStore, WishlistAlbumsSimilar, WishlistAlbumsShow, WishlistAlbumsUpdate, WishlistAlbumsDestroy, WishlistAlbumsPurchase, WishlistReserve, WishlistReserveCancel, WishlistReveal, WishlistShareShow, WishlistShareUpdate, WishlistShareRegenerate, WishlistHousehold, WishlistSettingsShow, WishlistSettingsUpdate, WishlistSubscriptionsIndex, WishlistSubscribers, WishlistTokenSubscribeStatus, WishlistTokenSubscribe, WishlistTokenUnsubscribe, WishlistCollectionSubscribeStatus, WishlistCollectionSubscribe, WishlistCollectionUnsubscribe, WishlistPeerAlbumsIndex, WishlistPeerAlbumsShow.
  • Plugin: purchase mail job registered in Jobs() (worker sendWishlistPurchasedMail), templates golem15.fonoteka::mail.wishlist_item_purchased and -en; SetJobDispatcher wired at boot beside SetRealtimeService.
  • Routes: the 30 wishlist routes listed in RESEARCH "Wishlist JWT" and "Wishlist on the token group", without match/apply-release.
  • Parity: seed state wishlist; fixtures/nuxt/nuxt-wishlist.yaml, nuxt-wishlist.rows.json, fixtures/mcp/mcp-wishlist.yaml; broadcast goldens wishlist-item-added.yaml, wishlist-purchased.yaml, reservation-revealed.yaml; subtests TestFonotekaNuxtFlows/nuxt-wishlist, TestFonotekaNuxtFlows/mcp-wishlist, TestBroadcastGoldens/wishlist-item-added, /wishlist-purchased, /reservation-revealed.
  • Tests: TestWishlistOwnListAndShow, TestWishlistItemAddedOncePerPath, TestDigestCoalescing, TestWishlistShareSettingsHousehold, TestWishlistSubscriptions, TestReserveConcurrent, TestRevealIdempotent, TestReservationMask, TestPurchaseSideEffects, TestPurchaseMailAfterCommit, TestWishlistOverlapRoutesAssembled.
Task 1: A user opens their own wishlist on the web and through a personal token and sees reservations masked as PHP masks them The isolated PHP parity instance can be reset (`php -v` exits 0). ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (index, show, embedsFor, paginated), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ActiveWishlistResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistProvisioner.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumReservationService.php (stateForViewer), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (lines 55-140), ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (index and show shape), ../fonoteka.go/plugins/golem15/fonoteka/models/album_reservation.go, ../fonoteka.go/plugins/golem15/fonoteka/models/collection.go, ../fonoteka.go/parity/manifest.yaml (wishlist entries), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_wishlist_albums_jwt.yaml Per C-01, C-03 and RESEARCH "Wishlist" analysis.

(1) wishlist_resolver.go: ProvisionWishlist(ctx, tx, user) ports WishlistProvisioner (lock the users row FOR UPDATE, reuse the existing own kind='wishlist' collection, else create Moja lista życzeń); ActiveWishlist(ctx, db, user) ports ActiveWishlistResolver; WishlistsVisibleTo(db, userID) ports Collection::wishlistsVisibleTo (kind wishlist and owner among the owners and editors of every collection the user belongs to, the user included); ReservableWishlistIDs ports AlbumReservationService::reservableWishlistIds.

(2) reservations.go read side: ReservationDTO and ReservationStateForViewer(album, reservation, ReservationContext{ViewerID, IsOwner}) port stateForViewer's three-way mask (owner before reveal sees no reserved_by). serialize_album.go: SerializeAlbum takes an optional ReservationContext; with none, no reservation key is emitted so every existing caller stays byte-identical; conditional keys inside are omitted, not nulled, as PHP.

(3) wishlist_albums_controller.go WishlistAlbumsIndex (PHP index: own wishlist, paginated envelope without links, reservation context is_owner true) and WishlistAlbumsShow (PHP show; foreign or missing id → PHP's 404 JSON body as recorded). Routes: JWT GET /wishlist/albums, GET /wishlist/albums/{id} with [0-9]+; token group GET /wishlist/albums with inv.scope:read, sharing the index handler.

(4) Seed state wishlist in both seeds: alice's wishlist with three items (one reserved by bob, unrevealed; one revealed), share enabled, reservations allowed, bob subscribed by collection, notifications for bob. Re-record the three route fixtures from the reset (owner list, owner show of the reserved item, foreign id 404, token-group list with a read token). Flip; expectedPortedRoutes 116.

(5) wishlist_smoke_test.go TestWishlistOwnListAndShow: first read provisions one wishlist; the owner's list hides reserved_by for the unrevealed reservation and shows it for the revealed one; a non-wishlist album id answers 404. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistOwnListAndShow|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestWishlistOwnListAndShow" and "--- PASS: TestParityCorpus/coverage"; an existing album fixture fails (the optional reservation key leaked into existing bodies).</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 116. - grep -c 'json:"reservation,omitempty"' ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go prints 1. - grep -c 'Moja lista życzeń' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go prints at least 1. - grep -n '"/wishlist/albums"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go shows one JWT line and one token-group line carrying inv.scope:read. </acceptance_criteria> The wishlist read path works end to end on both groups with PHP's reservation mask, proving resolver, serializer, routes and recordings before the write and social features land.

Task 2: The owner adds, edits, removes and shares wishlist items, and followers get a bell notification and a queued digest for each new item The isolated PHP parity instance can be reset (`php -v` exits 0). ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (store, update, destroy, similar, rules lines 280-310), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistShareController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSettingsController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistHouseholdController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSimilarityFinder.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyWishlistItemAdded), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistSubscriptionService.php (subscribersOf), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/WishlistDigestQueue.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (lines 80-112), ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go (albumAddedCallback, NotifyAlbumAdded, init), ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (JobQueue), ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (store/update/destroy), ../fonoteka.go/plugins/golem15/fonoteka/realtime.go (SetRealtimeService wiring) (1) Item writes (C-04): `WishlistAlbumsStore` (201 `{"data","duplicate"}`), `WishlistAlbumsUpdate`, `WishlistAlbumsDestroy` port PHP onto the Phase 12 album write path targeting ActiveWishlist; PHP's Validator::make rules including `condition prohibited` and `shelf prohibited` through lagoon.ValidateRequest, failures via writeValidationFailed (422 `{"error":"Validation failed","errors"}` in PHP key order). Album writes keep the Phase 11 note (WithoutBroadcasting plus Service.Emit). `FindSimilarAlbums` ports AlbumSimilarityFinder with `ILIKE` and `\`, `%`, `_` escaped (Pitfall 7), optional year, photos embed, order by name, limit 6; `WishlistAlbumsSimilar` answers `{"wishlist":[...],"collection":[...]}`. Routes: JWT `POST /wishlist/albums`, `GET /wishlist/albums/similar` (before `{id}`), `PUT|DELETE /wishlist/albums/{id}` (`[0-9]+`); token group `POST /wishlist/albums` and `PUT|DELETE /wishlist/albums/{id}` with `inv.scope:write`.

(2) Item-added (D-08, discretion resolved): in notification_service.go, albumAddedCallback calls NotifyWishlistItemAdded for an album whose collection kind is wishlist (real collections keep NotifyAlbumAdded). wishlist_notifications.go ports notifyWishlistItemAdded with SubscribersOf (port of subscribersOf, including synthetic household peers with both channels on; put it in wishlist_subscriptions.go): skip the actor; ws_enabled → WriteNotification type wishlist_item_added with ordered payload album_id, album_name, collection_id, owner_name; email_enabled → EnqueueWishlistDigest(ctx, tx, subscriberID, wishlistID): one INSERT ... ON CONFLICT (user_id, collection_id) DO UPDATE SET item_count = item_count + 1, updated_at = NOW() RETURNING (xmax = 0) and, only when the row was inserted, Dispatch of WishlistDigestArgs with WishlistDigestJobQueue, WishlistDigestLabel and WishlistDigestDelay on the same transaction. The dispatcher comes from SetJobDispatcher(JobDispatcher) (an interface with the existing JobQueue Enqueue plus conga's Dispatch, satisfied by *conga.Manager), wired in realtime.go beside SetRealtimeService; a nil dispatcher when a digest must be dispatched fails the insert with ErrNoJobDispatcher (never a silent skip). Use a clean session on the callback's handle (the 12-04 cleanSession precedent).

(3) Share, settings, household (JWT only): WishlistShareShow/Update/Regenerate port WishlistShareController on ActiveWishlist with the existing share service (/w/ paths, regenerate under the share row lock, throttle:10,1 on regenerate); WishlistSettingsShow/Update (reservations_allowed required|boolean, written directly because it is outside the fillable set, 422 JSON on failure); WishlistHousehold ports the household index (8-album previews by name). Routes GET|PUT /wishlist/share, POST /wishlist/share/regenerate, GET /wishlist/household, GET|PUT /wishlist/settings.

(4) Recordings from the wishlist reset: store 201 (with and without a duplicate), store 422 with condition set (settles A2) and with a missing name, update 200 and foreign 404, destroy 200 and 404, similar, share show/update/regenerate ({{share:wishlist}}), settings show/update/422, household; token-group store/update/destroy with write and read-only tokens. Flip these 13 routes; expectedPortedRoutes 129.

(5) Tests: wishlist_notifications_test.go TestWishlistItemAddedOncePerPath (JWT store, token-group store and a direct CreateAlbum into a wishlist each produce exactly one bell row per ws-enabled non-actor subscriber and one digest row bump; nothing is written to the actor; a real-collection insert still produces album_added only) and TestDigestCoalescing (three inserts → item_count 3, one summer_jobs row with label wishlist_digest and one scheduled river_job about 1800 s ahead on fonoteka.wishlist.digest; a rolled-back insert leaves no row and no job); wishlist_smoke_test.go TestWishlistShareSettingsHousehold. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestWishlistItemAddedOncePerPath|TestDigestCoalescing|TestWishlistShareSettingsHousehold|TestWishlistOwnListAndShow|TestInvitationAcceptAddsEditor)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows|TestBroadcastGoldens)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestWishlistItemAddedOncePerPath, TestDigestCoalescing and TestWishlistShareSettingsHousehold; nuxt-albums or an existing broadcast golden regresses (album_added path changed).</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 129. - grep -c 'NotifyWishlistItemAdded' ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go prints at least 1. - grep -c 'xmax = 0' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go prints 1 and grep -c 'WishlistDigestLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go prints at least 1. - grep -c 'ILIKE' ../fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go prints at least 1. - The recorded 422 fixture for condition exists and its message text equals what Go emits (A2 settled by recording). - grep -c 'wishlist_digest\|fonoteka.wishlist.digest' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go prints 0 (no digest worker registered). </acceptance_criteria> Owners curate and share their wishlist, every new item reaches followers' bells immediately and joins one queued digest per follower window, with no worker pretending to send it.

Task 3: Followers subscribe, reserve gifts secretly and see peer wishlists, and the owner marks an item bought with mail sent only after commit The isolated PHP parity instance can be reset (`php -v` exits 0). ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_subscriptions_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_reservations_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_peer_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSubscriptionController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistSettingsController.php (subscriptions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumReservationController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistPeerAlbumController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (purchase), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/WishlistSubscriptionService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumReservationService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (moveToCollection lines 270-282), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyWishlistItemPurchased, notifyReservationRevealed, mailWishlistPurchased lines 237-262), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/CollectionShareService.php (resolvePublic used by subscribe-by-token), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go (invitation mail worker and deliverInvitationMail pattern), ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/collection_invitation.htm (template conversion precedent), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go (1) Subscriptions: wishlist_subscriptions.go ports `Subscribe` (upsert stamping subscribed_at), `Unsubscribe`, `FollowerCountOf`, `SubscriptionStateFor` (with `forced` for household peers) and `SubscriptionsFor` (household wishlists by owner name, then other subscriptions). The token routes resolve the wishlist by its public token with PHP's checks (the 16-character shape, `LOWER(public_token)` lookup, public_enabled, kind wishlist, constant-time compare; put the shared resolver in share_service.go as `ResolvePublic(ctx, db, token, kind)` so plan 13-05 reuses it); the collection routes require a wishlist from WishlistsVisibleTo. Handlers `WishlistTokenSubscribeStatus`/`WishlistTokenSubscribe` (201)/`WishlistTokenUnsubscribe`, `WishlistCollectionSubscribeStatus`/`WishlistCollectionSubscribe` (201)/`WishlistCollectionUnsubscribe`, `WishlistSubscribers`, `WishlistSubscriptionsIndex`; every PHP HttpException is the Winter 404 page.

(2) Reservations: ReserveAlbum (album row FOR UPDATE, reservable-wishlist check, own wishlist → ErrCannotReserveOwnWishlist 422 cannot_reserve_own_wishlist, disallowed → ErrReservationsDisabled 409 reservations_disabled, an existing reservation → ErrAlreadyReserved with PHP's 409, create → 201 with PHP's body), CancelReservation (reserver only, else the Winter 404 page), RevealReservation (owner only, idempotent, stamps revealed_at once and calls NotifyReservationRevealed, bell only, {"data":{"reserved":false}} without a reservation), ReleaseForAlbum. Handlers WishlistReserve, WishlistReserveCancel, WishlistReveal.

(3) Purchase (D-07): MoveToCollection ports AlbumWriteService::moveToCollection inside one lagoon.Transaction: update collection_id to the caller's active collection, ReleaseForAlbum, then NotifyWishlistItemPurchased (bell rows for every ws-enabled subscriber including the actor, the reserver once if not already notified, payload album_id, album_name, collection_id) and, per email-enabled subscriber, Enqueue WishlistPurchasedMailArgs{SubscriberID, AlbumName, WishlistName} on WishlistPurchasedMailQueue with WishlistPurchasedMailAttempts on the same transaction. WishlistAlbumsPurchase answers PHP's 200 body. The album update broadcast follows the Phase 11 note.

(4) Mail: register the purchase mail job in Jobs() with conga.Job, OnQueue(WishlistPurchasedMailQueue) and MaxAttempts; the worker loads the subscriber (skip with an id-only info log when gone), picks golem15.fonoteka::mail.wishlist_item_purchased-en for preferred_locale en and the Polish template otherwise, and sends vars albumName and wishlistName; port both templates with PHP's front matter, subject and the plytarium layout; add them to MailTemplates. Never log args.

(5) Peer albums: WishlistPeerAlbumsIndex and WishlistPeerAlbumsShow port WishlistPeerAlbumController through ReservableWishlistIDs with the viewer's reservation context; invisible, foreign or missing → Winter 404 page.

(6) Routes, JWT group only, in routes.php order: GET /wishlist/subscribers, GET /wishlist/subscriptions, GET|POST|DELETE /wishlist/token/{token}/subscribe, GET|POST|DELETE /wishlist/{collectionId}/subscribe ([0-9]+), POST /wishlist/albums/{id}/purchase, POST|DELETE /wishlist/albums/{id}/reserve, POST /wishlist/albums/{id}/reveal (each [0-9]+), GET /wishlist/{collectionId}/albums, GET /wishlist/{collectionId}/albums/{albumId} (both [0-9]+). Never mount match or apply-release.

(7) Recordings from the wishlist reset with PHP's error pages: every route's success case plus its distinct errors (not subscribed, invisible wishlist, foreign reserver cancel, reserve own 422, reserve disabled 409, double reserve 409, reveal by non-owner, reveal without reservation, purchase foreign 404, peer invisible). Flip these 14 routes; expectedPortedRoutes 143.

(8) Tests: wishlist_reservations_test.go TestReserveConcurrent (two goroutines: one 201, one 409, one row), TestRevealIdempotent (second reveal writes nothing new), TestReservationMask (owner, reserver and third party views before and after reveal), TestWishlistSubscriptions (forced household state, follower count, subscribe by disabled token 404); wishlist_notifications_test.go TestPurchaseSideEffects (move, release, recipients including actor and reserver once) and TestPurchaseMailAfterCommit (commit: one river_job per email subscriber, worker run sends the right template, recipient and locale to the postcard memory driver; rollback: no job, no mail); TestWishlistOverlapRoutesAssembled (the assembled router sends each of the four 13-01 overlap pairs to the real handler). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestReserveConcurrent|TestRevealIdempotent|TestReservationMask|TestWishlistSubscriptions|TestPurchaseSideEffects|TestPurchaseMailAfterCommit|TestWishlistOverlapRoutesAssembled|TestRouteTablePhase12)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks a "--- PASS" line for each of the seven new named tests; the parity run reports FAIL for a wishlist subtest or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 143. - grep -cE '/wishlist/albums/\{id\}/(match|apply-release)' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 0 and both manifest entries read status: pending. - awk '/r.Group\("\/api\/v1\/fonoteka"/,/^\t}\)/' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -cE 'wishlist/(share|settings|subscri|household|token|\{collectionId\})|/reserve|/reveal|/purchase|/similar' prints 0 (none of these is on the token group). - grep -c 'wishlist_item_purchased' ../fonoteka.go/plugins/golem15/fonoteka/mail.go prints at least 2 and grep -c 'layout = "plytarium"' ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm prints 1. - grep -c 'func ResolvePublic(' ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go prints 1. </acceptance_criteria> Following, secret reservations, reveal, peer browsing and purchase all behave as in PHP, with purchase mail delivered by a transactional job and every other wishlist route ported.

Task 4: The Nuxt wishlist journey and the MCP wishlist tools replay end to end, with notifications, publications and digest rows matching PHP The isolated PHP instance can serve with `QUEUE_CONNECTION=database` and the tide fake Centrifugo recorder (`summer parity:broadcasts`) can listen on 127.0.0.1:8424. ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json, ../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml, ../fonoteka.go/parity/fixtures/broadcasts/wishlist-item-added.yaml, ../fonoteka.go/parity/fixtures/broadcasts/wishlist-purchased.yaml, ../fonoteka.go/parity/fixtures/broadcasts/reservation-revealed.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/README.md /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ (wishlist composables), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (wishlist calls), /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 295-307), ../fonoteka.go/parity/fonoteka_flows_test.go (replayNuxtCollections, replayNuxtAlbums, isolatedFlowDB), ../fonoteka.go/parity/fixtures/nuxt/nuxt-collections.yaml, ../fonoteka.go/parity/fixtures/mcp/mcp-tools.yaml, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/README.md (Broadcast goldens, Phase 13 recording), ../fonoteka.go/parity/php_parity.sh (rows), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 4 and 8) Per D-12 and D-13 (RESEARCH Findings 4 and 8).

(1) nuxt-wishlist: author the request spec in the order the Nuxt composables issue it (alice creates two items, opens share and enables it, bob subscribes by token and by collection, GET notifications as bob, bob reserves an item, alice lists (masked), alice reveals, GET notifications as bob, alice purchases, GET notifications as bob and as alice, settings show/update, household, bob lists alice's wishlist as a peer) with the app's headers, record it with QUEUE_CONNECTION=database php_parity.sh serve so the digest and purchase-mail jobs stay queued, captures for every id and share:wishlist. After the recording, dump the digest-queue rows with php_parity.sh rows using a SELECT that joins users.email and collections.name (no ids) into nuxt-wishlist.rows.json. TestFonotekaNuxtFlows/nuxt-wishlist replays it on an isolated database from the wishlist seed and compares the same SELECT on Postgres with the rows golden (email, collection name, item_count), plus asserts one summer_jobs row per digest window with label wishlist_digest.

(2) mcp-wishlist: record the fonoteka-mcp wishlist calls (list, create, update, delete through /api/v1/fonoteka/wishlist/albums) with a pinned personal token from the vars store; TestFonotekaNuxtFlows/mcp-wishlist replays them.

(3) Publications: with summer parity:broadcasts record the notification:new and notification:count publications for item-added, purchase (under sync with no email-enabled subscriber for the album updated broadcast, as Finding 4 describes) and reveal into the three goldens; add TestBroadcastGoldens subtests wishlist-item-added, wishlist-purchased, reservation-revealed using the 13-01 payload id and created_at masks.

(4) parity/README.md: the nuxt-wishlist recipe (database queue, rows dump, broadcasts) and the mcp-wishlist recipe. Fixtures carry only {{...}} references for tokens (check_corpus --check-secrets). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestBroadcastGoldens|TestParityCorpus)$' -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; the run prints "--- FAIL", "--- SKIP" or "no tests to run", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-wishlist", "--- PASS: TestFonotekaNuxtFlows/mcp-wishlist", "--- PASS: TestBroadcastGoldens/wishlist-item-added", "--- PASS: TestBroadcastGoldens/wishlist-purchased" and "--- PASS: TestBroadcastGoldens/reservation-revealed"; check_corpus reports a secret, an unrecorded route or a ported case-status mismatch.</fails_when> <acceptance_criteria> - test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json succeeds and the file holds at least one row with an item_count key. - grep -c 'share:wishlist' ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml prints at least 1 and grep -cE 'inv_[A-Za-z0-9]{20,}' ../fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml prints 0. - grep -c '/notifications' ../fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml prints at least 3. - grep -c 'QUEUE_CONNECTION=database' ../fonoteka.go/parity/README.md prints at least 1. </acceptance_criteria> The whole wishlist journey of both real clients replays against Go, and its side effects (bell rows via the API, publications, digest rows) are proven equal to PHP's.

<threat_model>

Trust Boundaries

Boundary Description
JWT client → wishlist routes Users act on their own wishlist and on peers' wishlists only through visibility scopes
Personal token → token-group wishlist routes MCP clients reach only list/create/update/delete of the token owner's wishlist
Write transaction → notifications, publications, jobs, mail Side effects must follow the transaction outcome
Reservation data → wishlist owner The giver's identity is hidden until reveal

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-04 Elevation of Privilege reserve, cancel, reveal high mitigate Own wishlist → 422; reveal only through the caller's own wishlist; cancel only by the reserver (Winter 404 otherwise); TestReservationMask and TestReserveConcurrent (Task 3).
T-13-05 Information Disclosure reservation mask in every serializer path high mitigate ReservationStateForViewer omits reserved_by for the owner before reveal on index, show and peer routes; TestReservationMask and TestWishlistOwnListAndShow (Tasks 1, 3).
T-13-06 Information Disclosure / Tampering peer wishlists, subscriptions, wishlist album ids high mitigate ReservableWishlistIDs / WishlistsVisibleTo / ActiveWishlist scoping; identical Winter 404 pages for foreign and missing; TestWishlistSubscriptions and peer cases (Tasks 2, 3).
T-13-07 Elevation of Privilege token group high mitigate Only list/create/update/delete are mounted on /api/v1/fonoteka with one inv.scope each; acceptance grep plus TestRouteTablePhase13 in 13-06 (Task 3).
T-13-28 Tampering / Repudiation item-added and purchase side effects medium mitigate Bell rows, digest upsert, Dispatch and Enqueue run on the write transaction; publications go out after commit; TestDigestCoalescing and TestPurchaseMailAfterCommit rollback cases (Tasks 2, 3).
T-13-29 Information Disclosure subscribe by token medium mitigate ResolvePublic requires public_enabled, kind wishlist and a constant-time exact token match; a disabled or regenerated token answers 404 (Task 3).
T-13-30 Information Disclosure purchase mail job args and logs medium mitigate Args carry subscriber id and the two names only (no address, no token); the worker never logs args (Task 3).
T-13-SC Tampering package installs low accept No new dependency in this plan.
</threat_model>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 143 ported and passing; nuxt-wishlist, mcp-wishlist and the three new broadcast goldens pass; check_corpus `--require-recorded --check-secrets` green.

<success_criteria>

  • 30 wishlist routes ported with PHP bodies and error pages; match/apply-release still pending.
  • Item-added, purchase and reveal side effects match PHP (rows, publications, digest rows), mail only after commit, no digest worker.
  • Both real clients' wishlist journeys replay green. </success_criteria>
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md` when done.