diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md
new file mode 100644
index 0000000..0a246eb
--- /dev/null
+++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md
@@ -0,0 +1,1099 @@
+# Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes - Research
+
+**Researched:** 2026-10-02
+**Domain:** Go port of the remaining PHP Płytarium API surface (parity-driven): wishlist, reservations, notifications, CSV import/export, BYOK credentials, onboarding, invitation inspection, anonymous public views, plus their jobs and rate limits
+**Confidence:** HIGH for the PHP contract, the Go seams and the framework gaps (all read line by line or probed this session); MEDIUM for sizing and the plan split
+
+## Summary
+
+Phase 13 ports 58 routes, all of which already have a recorded fixture, and leaves 4 pending for Phase 14. The work splits into three parts:
+
+1. **Wishlist (30 routes):** about 1,480 PHP controller lines plus five services. They reuse the Phase 12 album write path almost entirely.
+2. **CSV import (6 routes) and export (2 routes):** a 578-line controller over roughly 1,000 lines of parser classes.
+3. **Small surfaces (22 routes):** notifications, credentials, onboarding, invitation inspection and the public views.
+
+The Go repo already has every model (with unique constraints), the notification writer, the share service, the gates, the invitation consume path and the mail-job pattern. The missing pieces are listed per area below.
+
+The research found **eight problems that block or change a decision**. They go to the planner, and the first two go to the user before PLAN.md is written:
+
+1. **The router cannot register the wishlist routes as they are.** The framework's `surf` router maps PHP routes straight onto Go `net/http` ServeMux patterns. Go treats four pairs of wishlist routes as conflicting and panics, so the app fails at boot with `surf: route conflict`. Examples are `GET wishlist/{collectionId}/albums` against `GET wishlist/albums/{id}`, and `DELETE wishlist/{collectionId}/subscribe` against `DELETE wishlist/albums/{id}`. PHP's numeric `->where()` constraints make each pair disjoint, but ServeMux ignores those constraints. A framework fix in `surf` is needed (Finding 1).
+2. **Enqueuing a job with no registered worker fails while the in-process worker runs.** This is the production setting (`work_in_serve: true`). D-04 and D-08 enqueue CSV and digest jobs with no worker until Phase 14. In that mode `conga` inserts through the worker client, and River refuses an unknown kind (`UnknownJobKindError`), so commit, mapping and item-added would return 500 in production while every test passes. Separately, a job of an unregistered kind placed on a queue the worker serves is fetched, fails and is discarded. Both need a framework fix plus a queue-naming rule (Finding 2).
+3. **`fonoteka.csv.import` and `fonoteka.csv.match` are PHP `JobManager` labels, not queue names.** They become the `summer_jobs.label` column. PHP pushes these jobs to the default queue. Go must keep the labels verbatim. The queue names D-03 chose still work, but only if no worker serves them (Finding 3).
+4. **PHP recordings run with `QUEUE_CONNECTION=sync`.** Under sync, PHP runs the CSV import job inside the commit request and runs the 1800 s digest job at once. The `nuxt-csv` and `nuxt-wishlist` recordings therefore need a non-sync queue to show the "queued, not run" state that D-04 and D-13 describe (Finding 4).
+5. **Most of the existing fixtures for these routes cannot be replayed.** They come from the Phase 2 bootstrap seed: placeholders collide (`{{id:token}}` stands where an album id belongs) and they use `seed_hook: none`. The manifest case status also disagrees with the fixture status on 16 in-scope routes, not just the one D-15 names. Every route must be re-recorded from the `fonoteka` reset, as Phase 12 did (Finding 5).
+6. **Three Go-side byte mismatches** (Finding 6):
+ - Go's public-share headers send `Cache-Control: private, no-store`, but PHP sends `no-store, private`, and tide compares the header byte for byte.
+ - The CSV export's recorded `Content-Disposition` header carries the recording date, so a later replay fails.
+ - PHP's `fputcsv` quotes fields that contain a space or a tab. Go's `encoding/csv` does not.
+7. **Every `$request->validate()` failure is a 500 HTML page in PHP, not a 422 JSON body.** This covers the credential stores and the onboarding bootstrap (Finding 7).
+8. **There is no tool that records database rows.** P11's D-10 tooling records Centrifugo publications only. For D-13 the planner has two routes: make the notification rows visible through API steps in the flows, and assert the `wishlist_digest_queue` rows in Go tests (Finding 8).
+
+**Primary recommendation:** six plans, run sequentially:
+
+1. Framework gaps and planning docs (`summercms.go`).
+2. Notifications, credentials, onboarding, invitation inspection and the user-plugin hook.
+3. Wishlist.
+4. CSV import and export.
+5. Public views with D-14.
+6. Unit tests, security tests and the gate.
+
+Recordings go into each feature plan. Take Findings 1–3 to the user first: they decide one framework change and the job-name contract D-03 calls costly.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Route matching with PHP `->where()` semantics | API / Backend (`surf` router, framework) | — | Constraint-aware dispatch belongs in the router, not in app handlers (Finding 1) |
+| Wishlist tenancy (own wishlist, visible peers, by-token resolution) | API / Backend (`classes`) | Database (scopes, row locks) | Server-resolved. The client never selects a wishlist except through numeric ids that are re-gated |
+| Reservation privacy mask | API / Backend (serializer) | — | `stateForViewer` hides the reserver from the owner until reveal |
+| Notification registry and realtime fan-out | API / Backend (write in tx) | Centrifugo (publish after commit, lighthouse) | P11 D-06: publications are enqueued on the transaction |
+| Purchase mail and digest | Background job (conga/River) | Mail (postcard) | D-07/D-08: enqueued in the write transaction |
+| CSV upload storage | Storage (private gocloud blob bucket) | — | Must never sit under the public uploads prefix |
+| CSV parse, map and export | API / Backend | — | Pure functions ported from PHP, with truth tables |
+| CSV job progress | Database (`summer_jobs`) | Background job | `show` reads progress/progress_max/status from the job row |
+| Credentials at rest | Database (`lagoon.Encrypted`, AES-GCM) | API / Backend | Secret-free responses, encrypted columns |
+| Anonymous public views | API / Backend | Rate limiter (`surf` buckets, a pubfail counter) | Token resolution plus anti-enumeration |
+| First-run bootstrap | API / Backend | Database (advisory lock and in-tx count) | Replay-safe single owner |
+| Register hook | User plugin (event) → fonoteka listener | Database | Core-plugin contract, D-09 |
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Phase 13/14 boundary
+- **D-01:** `POST wishlist/albums/{id}/match` and `apply-release` (WishlistReleaseMatchController) move to Phase 14 with the Discogs client (INTG-01), next to the album match/apply-release routes. Their manifest entries stay `pending` (P6 D-15, no 501 shells). Success criterion 1 and API-03 are reworded at plan time.
+- **D-02:** Credentials CRUD lands in Phase 13: `GET/POST ai-credential`, `GET/POST/DELETE org-ai-credential`, `GET/POST discogs-credential` (including the `shared=true|false` mirror into the org credential, owner/admin only, else 403), encrypted storage, `FONOTEKA_AI_ORG_LOCK` org-lock and the PHP resolution order (AI: admin → backend Settings global model, then org-lock tier, user, org, else error; Discogs: admin → env `DISCOGS_TOKEN`, then user, org). The two live-call routes `ai-credential/test` and `discogs-credential/test` stay `pending` for Phase 14 (Discogs client and AI adapters). Success criterion 4 and API-06 are reworded at plan time.
+- **D-03:** All 7 CSV routes are ported in Phase 13 (`POST import/csv` 202 with throttle:10,1, `GET import/csv/{id}`, `PATCH .../mapping`, `PATCH .../rows/{rowId}`, `POST .../commit`, `POST .../cancel`, `GET export/csv` on both groups). `commit` (after the preview→importing compare-and-swap, idempotent 202 on replay) and non-canonical `mapping` enqueue real River jobs whose kinds, queues (`fonoteka.csv.import`, `fonoteka.csv.match`) and args mirror PHP's `AlbumCsvImportJob`/`AlbumCsvMatchJob`. The job bodies are JOBS-02 in Phase 14. `cancel` marks the job rows canceled as PHP does; `show` reads progress from the job table. — **Reversibility:** costly — the job kind names and args become the contract that Phase 14 workers consume and that queued rows in a live DB already carry.
+- **D-04:** Until Phase 14, no worker is registered for the CSV import/match kinds. The queued rows simply wait and `show` reports the state PHP reports before its worker picks the job up. No fake success, no stub worker.
+- **D-05:** The row-edit branch with `selected_discogs_id` (PHP calls `DiscogsClient::getRelease` inline) is held behind a narrow seam that Phase 14 fills. The rest of the row-edit route is ported. How that branch behaves in the meantime (and which recorded case stays pending) is decided at planning, with the constraint that it never fabricates release data.
+- **D-06:** Notifications: PHP has no prune route; pruning is the `fonoteka:prune-notifications` console command, already in Phase 14. Success criterion 2 and API-04 drop "prune" at plan time. The 4 routes (`notifications` capped at 50 newest-first, `unread-count`, `read-all`, `{id}/read`) are ported.
+
+#### Wishlist mail and digest
+- **D-07:** `wishlist/albums/{id}/purchase` keeps PHP's DB effects (move via `collection_id` update, `releaseForAlbum`, bell notifications, reserver notification) but subscriber mail is sent by a River job enqueued in the purchase transaction, sent only after commit (P12 D-13 pattern). PHP sends it inline; response bodies and DB state still match.
+- **D-08:** Adding a wishlist item writes bell notifications immediately and upserts the `wishlist_digest_queue` row exactly as `WishlistDigestQueue::enqueue` does (first enqueue in a window creates the row and enqueues the 1800 s delayed digest job; later ones bump `item_count`). The delayed River job is enqueued with the PHP-equivalent kind and args but has no worker until Phase 14, which adds the worker, the digest mail and the queue-row delete (JOBS-03).
+
+#### User plugin register hook (core plugin change, signed off)
+- **D-09:** `sm-user-plugin`'s `RegisterEvent` gains a generic `Payload map[string]any` carrying the raw register input, mirroring Winter's `golem15.user.register` payload. Additive and non-breaking: existing listeners compile unchanged, the user plugin learns nothing about invitations, and its README is updated in the same change. The user signed off on this core-plugin change on 2026-10-02. — **Reversibility:** costly — once other plugins read `Payload`, removing or reshaping it breaks them across every project using the user plugin.
+- **D-10:** fonoteka registers a listener that ports `Plugin.php:180`: hash `Payload["invitation_token"]`; if it matches a valid invitation for the user's email, upsert `PendingInvitationRegistration` (consumed later by the existing `guardPendingInvitation`); otherwise provision a collection.
+- **D-11:** `onboarding/bootstrap` registers the first user through the user plugin's exported registration path so password hashing, the register event and JWT issuance are identical to `/register`. If a needed function is not exported, it is exported additively (same sign-off as D-09). Bootstrap keeps PHP's validation (`org_name`, `email`, `password`), the cache-lock race guard and the 409 on replay.
+
+#### Parity evidence
+- **D-12:** New recordings against the isolated PHP instance with the Phase 2 `tide` capture rules (private 0600 vars, no live tokens in git):
+ - `nuxt-wishlist`: create item → share show/update/regenerate → subscribe by token and by collection → reserve as a second user → reveal → purchase → settings → household → peer albums.
+ - `nuxt-csv`: store → show → mapping → row edit → commit (to its 202 and the queued job row) → cancel, then CSV export on both groups. Jobs do not run (D-04).
+ - `public-anonymous`: `public/{token}` and `public-wishlist/{token}` resolve, albums, album detail, a bad token, and the `pubfail:` anti-enumeration 429 after 10 failures.
+ - `mcp-wishlist`: the MCP token-group wishlist CRUD (`fonoteka-mcp/src/client.ts:295-307`).
+ - `onboarding`: status and bootstrap on an empty DB, then register with `invitation_token`.
+ - Plus every distinct error status/body per route, with envelopes reproduced per endpoint (P7 D-13).
+- **D-13:** Side effects of purchase and item-added are verified by diffing recorded `notifications` and `wishlist_digest_queue` rows and the Centrifugo publications (P11 D-10 tooling, `timestamp`/`actor` normalised). Mail recipients, locale and template are asserted in Go tests through postcard's `memory` driver.
+
+#### Fixes without a decision (parity rule)
+- **D-14:** The public group's `resolve` routes (`public/{token}`, `public-wishlist/{token}`) get PHP's inline `throttle:10,1` only. `fonoteka-public-token` + `fonoteka-public-ip` apply only to the albums index/show routes. Currently `routes.go` applies both buckets at group level.
+- **D-15:** The manifest case for `GET invitations/{token}` expects 404 while its recorded fixture is 200 `{"data":{"state":"unavailable"}}`. The manifest is corrected to the fixture.
+
+#### Carried forward (locked earlier)
+- **C-01:** One handler per route, mounted on both groups where PHP mirrors it, with PHP's exact `->where()` constraints, inline throttles and exactly one `inv.scope:read|write` per token-group route (P12 D-01, D-10, D-26).
+- **C-02:** Error bodies as production PHP serves them (`APP_DEBUG=false`), including Winter HTML 404 pages where PHP throws `HttpException` (notifications `{id}/read`, reserve DELETE, reveal, subscriptions) (P12 D-21). CSV errors keep `{"result":"error","code","message"}`; public 404/429 keep `{"error":"Not found"}` / `{"error":"Too many requests"}`.
+- **C-03:** Response DTOs from ported `Serialize*` functions, `[]` not `null`, Carbon `+00:00`, pagination envelope without `links` (P5 D-08, P6 D-17).
+- **C-04:** Write paths go through ported fill boundaries; the request-DTO fuzz covers every write endpoint of this phase (P12 C-02).
+- **C-05:** Mail via River job in the write transaction; raw tokens in job args encrypted with the app key (P12 D-13, D-23).
+- **C-06:** Public tokens: 16 chars `[0-9a-zA-Z]`, `LOWER()` lookup plus constant-time compare, no route regex, `PublicShareHeaders` (`X-Robots-Tag: noindex, nofollow`, `Cache-Control: no-store, private`). public-wishlist mirrors public collection with `kind='wishlist'` and no reservations.
+- **C-07:** Unported routes stay `pending`; pending never counts as passing (P2, P6 D-15).
+
+### Claude's Discretion
+- Whether the wishlist item-added branch extends the existing `albumAddedCallback` GORM hook or is an explicit write-service call, provided every create path (JWT, token-group POST, any bulk path) triggers it exactly once and only after commit.
+- Handler file layout and how the ~1,480 lines of wishlist controllers and the 578-line CSV controller are split across Go files.
+- Job kind names for purchase mail and digest (must be stable once chosen, see D-03 reversibility).
+- The interim behaviour of the `selected_discogs_id` row-edit branch (D-05), within its constraint.
+- Which error cases are recorded versus Go-tested with bodies from PHP source where recording is impractical. Recording is the default.
+- Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent (this phase touches public tokens, credentials encryption and authorization).
+
+### Deferred Ideas (OUT OF SCOPE)
+- Wishlist `match`/`apply-release`, credential `/test` routes, CSV job workers, the `selected_discogs_id` row-edit branch, the digest worker and mail, and `fonoteka:prune-notifications` — all Phase 14.
+
+#### Reviewed Todos (not folded)
+- `redacting-slog-handler.md` — relevant to credentials but a framework logging concern; stays a standalone todo.
+- `backend-admin-api-tokens.md`, `refresh-fonoteka-readme.md`, `rewrite-summercms-readme.md`, `per-module-readmes-after-nest.md`, `readme-go-fences-src.md` — keyword matches only, unrelated to this phase's routes.
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| API-03 | Wishlist: items, subscriptions, public-wishlist/{token} views, album reservations (reserve/reveal), purchase and digest triggers | Route inventory §Wishlist (30 ported, match/apply-release pending per D-01). Wishlist area section, the jobs table (purchase mail, digest), Findings 1, 2 and 4. Reword per D-01. |
+| API-04 | Notifications: list, mark read, prune; realtime token endpoint owned by the websockets plugin | Notifications area section. "prune" is dropped per D-06 (console command, Phase 14). |
+| API-05 | CSV import as a multi-step session (store, show/poll, mapping patch, per-row edit, commit, cancel) and CSV export on both authenticated groups | CSV area section, jobs table (labels and kinds), Findings 2, 3, 4 and 6, D-05 seam design |
+| API-06 | Per-user and per-org Discogs and AI credentials CRUD with encrypted storage, org-lock flag, and env-to-org-to-user resolution | Credentials area section. `/test` routes stay pending per D-02. Finding 7 (500 pages). Reword per D-02. |
+| API-07 | Onboarding, public and invitation inspection routes, including the anonymous collection public-token views, with their public rate-limit buckets | Onboarding/public area section, rate limits (D-14), the pubfail counter, the user-plugin hook (D-09..D-11), the D-15 manifest fix |
+
+
+## Project Constraints (from CLAUDE.md)
+
+- **Lean planning.** Plan as few, large plans. Before writing any PLAN.md, present the plan count with one-line scopes and wait for confirmation.
+- **Unit tests come last.** The last plan of the phase brings full unit-test coverage. Earlier plans may carry smoke tests.
+- **Standard library first.** Add a dependency only when the research or a phase decision names one. This phase needs **no new dependency**. `go vet` and `go test ./...` stay green at every commit.
+- **Compiled plugins.** No runtime plugin loading.
+- **API parity is the acceptance test.** Do not improve response shapes.
+- **Two repositories.** The framework is `summercms.go`, the app is `fonoteka.go`, and the user plugin is the `sm-user-plugin` submodule at `fonoteka.go/plugins/golem15/user`.
+ - Framework READMEs never name the app.
+ - Planning docs stay in `summercms.go/.planning`.
+ - Manage the submodule with `ssu`. The commit lands in the submodule first, then a pointer bump goes into `fonoteka.go`, as in Phase 12 (`f60c3af chore(12-05): bump sm-user-plugin ...`).
+- **Documentation.** Any exported-API, config or CLI change to a `modules/` package updates that module's `README.md` and the affected `docs/` pages in the same change. This applies here to `surf`, `conga`, `lagoon` and `tide`.
+ - Checkers: `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`.
+ - Every identifier named in a doc must exist.
+- **Commits.**
+ - No co-author tags. This follows the user's global instruction and the project CLAUDE.md, and it overrides the harness attribution reminder.
+ - One logical change per commit.
+ - Planning docs and code go in separate commits.
+- **Core plugin contracts.** The user, blog, pages and payment plugins must not break. D-09 and D-11 are signed-off additive changes to the user plugin, and nothing else in the user plugin may change.
+- **GSD.** Edits go through a GSD workflow.
+
+## Critical Findings (read before planning)
+
+### Finding 1: Four wishlist route pairs crash the router at boot (framework gap in `surf`)
+
+`surf` compiles each route straight to `mux.Handle(rt.method+" "+rt.path, h)` and turns a ServeMux panic into an error [VERIFIED: summercms.go/modules/surf/router.go:346-367]:
+
+```go
+ mux.Handle(rt.method+" "+rt.path, h)
+```
+
+The `Where` constraints are applied inside the handler (`h := constrain(rt.handler, rt.constraints)`, router.go:372), after ServeMux has already picked the route. A Go 1.27 probe of every Phase 13 route pair found exactly four conflicts [VERIFIED: ServeMux probe this session]:
+
+```
+CONFLICT: GET /_fonoteka/api/v1/wishlist/token/{token}/subscribe <> GET /_fonoteka/api/v1/wishlist/{collectionId}/albums/{albumId}
+CONFLICT: GET /_fonoteka/api/v1/wishlist/{collectionId}/subscribe <> GET /_fonoteka/api/v1/wishlist/albums/{id}
+CONFLICT: DELETE /_fonoteka/api/v1/wishlist/{collectionId}/subscribe <> DELETE /_fonoteka/api/v1/wishlist/albums/{id}
+CONFLICT: GET /_fonoteka/api/v1/wishlist/albums/{id} <> GET /_fonoteka/api/v1/wishlist/{collectionId}/albums
+```
+
+In PHP every pair is disjoint because of the `->where()` constraints. `collectionId`, `id` and `albumId` all carry `[0-9]+` [VERIFIED: routes.php:174-176, 204-206, 224-225], and the literals `albums`, `token` and `subscribe` never match `[0-9]+`. Laravel also matches routes in registration order.
+
+**Action (plan 13-01, summercms.go):** make `surf` dispatch constraint-aware when patterns overlap. When two routes with the same method conflict on ServeMux:
+- Register one generalized pattern, with a wildcard wherever the two patterns differ.
+- Its dispatcher tries the candidate routes in registration order. It checks literal segments and constraints, sets path values with `Request.SetPathValue`, and falls through to the not-found handler when nothing matches.
+- The route table (`routetable.go`, `route:list`) must still list each route separately, with its own constraints and middleware, so C-01 and the route-table tests keep their meaning.
+
+Update the `surf` README and docs in the same change.
+
+The rejected alternative is a single app-level `GET wishlist/{a}/{b}` handler. It breaks C-01 ("one handler per route ... exact `->where()` constraints"), the route-table tests and `route:list` parity.
+
+### Finding 2: Unregistered job kinds fail while the worker runs, and are discarded on a served queue (framework gap in `conga`)
+
+**Insert path.** `Manager.insertClient()` returns the worker client whenever one is running [VERIFIED: summercms.go/modules/conga/conga.go:458-466]:
+
+```go
+ if m.worker != nil {
+ return m.worker, nil
+ }
+```
+
+The worker client has `Workers` set and the default `SkipUnknownJobCheck: false` [VERIFIED: conga/client.go:102-113, worker.go:117-130]. River then rejects the insert [VERIFIED: river@v0.47.0/client.go:2270-2276]:
+
+```go
+ if c.config.Workers == nil || c.config.SkipUnknownJobCheck {
+ return nil
+ }
+
+ if _, ok := c.config.Workers.workersMap[args.Kind()]; !ok {
+ return &UnknownJobKindError{Kind: args.Kind()}
+ }
+```
+
+`work_in_serve` defaults to `true` (client.go:39) and the app sets `work_in_serve: true` [VERIFIED: fonoteka.go/config/queue.yaml:4]. In production, then, `POST import/csv/{id}/commit`, a non-canonical `PATCH .../mapping` and every wishlist item-added with an email-enabled subscriber would fail their transaction. Tests never start the worker, so they always use the insert-only client (`Workers` nil) and pass.
+
+**Fetch path.** The worker serves `knownQueues` = `default` plus every registered job's queue plus every configured queue [VERIFIED: conga/client.go:78-89]. River fails a fetched job with an unknown kind [VERIFIED: river@v0.47.0/internal/jobexecutor/job_executor.go:219 `return &jobExecutorResult{Err: &rivertype.UnknownJobKindError{Kind: e.JobRow.Kind}, ...}`]. The job is retried and finally discarded.
+
+**Action (plan 13-01):**
+- (a) `conga` inserts kinds that have no registered job through the insert-only client, which never checks kinds. The alternative is setting `SkipUnknownJobCheck: true` on the worker client. Add a test: "Enqueue/Dispatch of an unregistered kind succeeds while a worker runs". Update the README.
+- (b) Phase 13 jobs without a worker use queues that nothing configures or registers: `fonoteka.csv.import`, `fonoteka.csv.match` and a digest queue such as `fonoteka.wishlist.digest`. Never `default` or `mail`, and do not add them to `config/queue.yaml` until Phase 14 registers their workers.
+
+### Finding 3: The CSV "queues" in D-03 are PHP job labels
+
+PHP dispatches through Apparatus `JobManager::dispatch($job, $label, $parameters, $delay)` [VERIFIED: apparatus/classes/JobManager.php:59-97]. That call inserts a `golem15_apparatus_jobs` row with that `label`, `progress_max` = `count` and `metadata` = `json_encode($metadata)`, then calls `$this->queue->push($job)`. That push goes to the default queue, because the job classes declare no `$queue` [VERIFIED: jobs/AlbumCsvImportJob.php:23-34]. The calls:
+- `'fonoteka.csv.match'` with `['count' => (int) $import->row_count, 'metadata' => ['csv_import_id' => $import->id]]` [VERIFIED: CsvImportApiController.php:167-174]
+- `'fonoteka.csv.import'` with the same parameters [VERIFIED: CsvImportApiController.php:306-313]
+- `'wishlist_digest', [], 1800` [VERIFIED: models/WishlistDigestQueue.php:37-42]
+
+The returned id is stored in `match_job_id` / `import_job_id`. `show` reads `progress`, `progress_max` and `status` from that row [VERIFIED: CsvImportApiController.php:543-558]. `cancel` sets `is_canceled = true` and `status = STOPPED (4)` [VERIFIED: CsvImportApiController.php:348-358, JobManager.php:222-233, contracts/JobStatus.php `const STOPPED = 4;`].
+
+**Mapping onto Go:** `conga.Dispatch` writes the same row into `summer_jobs`. Its statuses equal the Apparatus constants [VERIFIED: conga/record.go:9-23]. Its nil metadata stores `""`, as PHP's `json_encode('')` does. `conga.CancelJob` sets `is_canceled` and `StatusStopped` and cancels the River job [VERIFIED: conga/conga.go:321-346].
+
+**Action:** keep D-03's queue names. Set the `summer_jobs.label` verbatim to `fonoteka.csv.import`, `fonoteka.csv.match` and `wishlist_digest`. Put this correction to the user, because D-03 calls the names "queues" and marks them costly. See the jobs table.
+
+**Minor difference:** PHP's `JobManager::dispatch` reads `\Auth::getUser()`, Winter's session auth, which is null on JWT requests (Plugin.php:118-133 explains this). PHP rows therefore get `user_id = NULL`, while `conga.Dispatch` stores the bouncer principal [VERIFIED: conga.go:166-176]. Replays never see the column. Record it as a known difference, or dispatch with a principal-free context if the user wants exact DB parity.
+
+### Finding 4: Recording with `QUEUE_CONNECTION=sync` runs the jobs D-04 and D-13 expect to stay queued
+
+`php_parity.sh` exports `export QUEUE_CONNECTION=sync` [VERIFIED: parity/php_parity.sh:41]. Under sync:
+- `commit` runs `AlbumCsvImportJob` inside the request, so the import is `done` before the 202 returns.
+- `WishlistDigestQueue::enqueue` runs `WishlistDigestJob` at once, because the sync driver's `later()` does not delay [ASSUMED]. That job deletes the queue row and mails.
+
+Album broadcasts go through the queue too [VERIFIED: websockets/traits/BroadcastableModel.php:221-222]. Notification publications do not: they are synchronous HTTP calls to `CentrifugoClient::publish` [VERIFIED: websockets/classes/CentrifugoClient.php:90-108].
+
+**Action (plan 13-01):**
+- `php_parity.sh` honours `QUEUE_CONNECTION="${QUEUE_CONNECTION:-sync}"`.
+- `nuxt-csv` and `nuxt-wishlist` are recorded with `QUEUE_CONNECTION=database`. Winter ships the jobs migrations `2014_10_01_000010_Db_Jobs.php` and successors [VERIFIED: ls modules/system/database/migrations].
+- The purchase step's album `updated` broadcast, which is queued, is recorded separately with `parity:broadcasts` under sync, in a state with no email-enabled subscriber. The `notification:*` publications are captured under either queue mode.
+
+### Finding 5: The existing fixtures cannot be replayed
+
+The Phase 2 bootstrap recordings contain scrubber collisions: `{{id:token}}` stands in the CSV export body where an album id belongs, `{{id:genre}}` stands as a public album's id, and `{{id:wishlist-album}}` as an artist id. These routes carry no `seed_hook`. The manifest case status also disagrees with the recorded fixture status on 16 in-scope routes:
+- `POST wishlist/albums`: manifest 200, fixture 201.
+- `POST wishlist/token/{token}/subscribe` and the token-group `POST wishlist/albums`: manifest 200, fixture 201.
+- Recorded 404 although the manifest expects 200: `GET`/`POST wishlist/{collectionId}/subscribe`, purchase, reserve, reveal, `GET`/`PUT wishlist/albums/{id}`, both peer-album routes, both public `albums/{id}` routes, and the token-group `PUT wishlist/albums/{id}`.
+- `DELETE org-ai-credential`: manifest 404, fixture 200.
+- `GET invitations/{token}`: manifest 404, fixture 200 (the case D-15 names).
+
+[VERIFIED: manifest/fixture comparison script this session over parity/manifest.yaml and fixtures/routes/*.]
+
+**Action:** like Phase 12, re-record every in-scope case from the `fonoteka` reset (`seed_hook: fonoteka`, `PARITY_CASE` states), with `case` plus named error cases. Fix each manifest status from its new fixture. D-15 is one instance of this general fix.
+
+### Finding 6: Three byte-level mismatches the replay would catch
+
+1. **Public headers.** PHP sends `Cache-Control: "no-store, private"` [VERIFIED: fixtures/routes/GET___fonoteka_api_v1_public_{token}_public_share.yaml:14]. Go sets `dst.Set("Cache-Control", "private, no-store")` [VERIFIED: middleware/public_share_headers.go:32]. tide compares `Cache-Control` as exact strings [VERIFIED: tide/diff.go:43-90]. Fix the Go string to `no-store, private`. C-06 already writes it that way.
+2. **Export filename date.** The export fixture records `Content-Disposition: "attachment; filename=plytarium-kolekcja-2026-09-17.csv"`, and tide compares `Content-Disposition` [VERIFIED: fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml:15, tide/diff.go:43-50]. Add a tide normalizer that masks the date in `plytarium-kolekcja-YYYY-MM-DD.csv`, or give the Go export an injectable clock and freeze it in the replay.
+3. **CSV quoting.** PHP's `fputcsv($o, ..., ',', '"', '')` quotes fields that contain a space or a tab. Go's `encoding/csv` does not [VERIFIED: probe this session — PHP: `"Kind of Blue",LP,"a b","x""y",,=SUM(1),plain,semi;colon,café`; Go: `Kind of Blue,LP,a b,"x""y",,=SUM(1),plain,semi;colon,café`]. PHP writes null as an empty field, false as an empty field and true as `1` [VERIFIED: same probe `,1959,0,,1`]. Port `fputcsv` as a small writer and do not use `encoding/csv.Writer`.
+
+### Finding 7: `$request->validate()` failures are Winter 500 pages
+
+With `APP_DEBUG=false`, a `ValidationException` is rendered as the generic "Błąd strony" page with status 500 and `Content-Type: "text/html; charset=UTF-8"`, not as a 422 JSON body [VERIFIED: fixtures/routes/POST___fonoteka_api_v1_household_invitations_jwt__missing-email.yaml (status: 500, `
Błąd strony`); parity/README.md "an invitation `ValidationException` ... goes through Winter's error handler as a 500"].
+
+The same path covers:
+- `AiCredentialController::store` and `OrgAiCredentialController::store`: `$request->validate` and `ValidationException::withMessages`.
+- `DiscogsCredentialController::store`: the same two calls, plus the regex message.
+- `OnboardingController::bootstrap`: `$request->validate`, including `unique:users`.
+
+The controllers that use `Validator::make` instead return the 422 JSON body `{"error":"Validation failed","errors":...}`: the wishlist album, settings, share and public index controllers.
+
+Go already has `winter_500.html` [VERIFIED: ls controllers/api/winter_500.html]. Record at least one failing case per store route.
+
+### Finding 8: No DB-row capture tool exists for D-13
+
+P11 D-10 is the Centrifugo publication recorder only: "Phase 2 `tide` tooling is extended to subscribe to the PHP stack's Centrifugo ... and store the publications as goldens" [VERIFIED: 11-CONTEXT.md:53]. `parity/db_capture.go` reads only user reset and activation codes [VERIFIED: db_capture.go:17-59].
+
+**Action:**
+- **Notification rows.** Make them HTTP-visible: the `nuxt-wishlist` flow adds `GET notifications` steps as each recipient after item-add, purchase and reveal. Their bodies are the rows `{id,type,payload,read_at,created_at}`.
+- **`wishlist_digest_queue` rows and job rows.** Assert in Go integration tests from PHP source semantics. The optional alternative is a tinker dump of the parity SQLite into a golden, which the planner should only build if the user insists on a recorded row diff.
+- **Publication masking.** `notification:new` publications carry `id` and `created_at` under `$.data.payload` [VERIFIED: NotificationService.php:273-278]. The tide normalizer masks only `timestamp`, `actor`, captured ids and `$.data.payload.album.*_at` [VERIFIED: tide/centrifugo_golden.go:278-327]. Either extend it with `$.data.payload.created_at` → `{{datetime}}` and a notification-id mask, or align the notification id sequence on both sides, as Phase 12 did by lifting the collection sequences.
+
+### Finding 9: Smaller framework gaps
+
+- **`lagoon` lacks `prohibited`.** The rule arity table has no `prohibited` and no `unique` [VERIFIED: lagoon/validate_rules.go:35-43]. The wishlist album rules need `'condition' => 'prohibited'` and `'shelf' => 'prohibited'` [VERIFIED: WishlistAlbumApiController.php:296, 304]. Laravel defines `validateProhibited` as `! $this->validateRequired(...)` and it is not implicit [VERIFIED: vendor/laravel/framework (9.x-dev) ValidatesAttributes.php:1821-1824, Validator.php:204-224]. Neither Winter's `pl` nor its `en` validation catalog has a `prohibited` key [VERIFIED: grep]. The message is therefore probably the literal key `validation.prohibited` [ASSUMED]. Record it.
+- **`unique:users` for bootstrap** needs no framework rule: any failure renders the same 500 page (Finding 7), so a Go-side existence check is enough.
+- **The public search mode is missing.** Go `SearchAlbums` is user- and token-scoped (`ScopedAlbums(ctx, db, p.UserID, p.Token, p.CollectionID)`) and hard-codes the authenticated text fields [VERIFIED: classes/album_search.go:22-30, 317-320]. The public index needs `TEXT_FIELDS_PUBLIC`: `'query_by' => 'name,artist_display,style_names,genre_name,track_titles'`, `'query_by_weights' => '10,10,5,5,3'`, `'sql_like' => ['name', 'artist_display', 'track_titles']`, `'sql_like_relations' => ['artists.name']` [VERIFIED: AlbumSearchService.php:60-65]. It also needs a collection-only scope with no ratings join. This is an app-level change in `classes/album_search.go`.
+
+## Route Inventory (62 in scope: 58 ported, 4 stay pending)
+
+Everything below is `status: pending` in `parity/manifest.yaml` today, and every route has a recorded fixture that exists on disk [VERIFIED: manifest scan]. "Fx" is the status recorded in the old fixture, which must be re-recorded (Finding 5).
+
+PHP prefixes:
+- **JWT group** (`J`): `/_fonoteka/api/v1`, `['jwt.auth','inv.must-change-password','bindings']` [routes.php:74-76].
+- **Token group** (`T`): `/api/v1/fonoteka`, `['bindings','throttle:fonoteka-api-token']` plus one `inv.scope` per route [routes.php:449-451].
+
+In the Go JWT group, `locale.from-principal` sits between those middlewares (routes.go:40).
+
+### Notifications (4, JWT) — `NotificationApiController` (68 lines)
+| Method | Path | Constraints / throttle | PHP | Fx | Plan |
+|---|---|---|---|---|---|
+| GET | notifications | — | @index (limit 50, `orderByDesc('id')`) | 200 | 13-02 |
+| GET | notifications/unread-count | — | @unreadCount | 200 | 13-02 |
+| POST | notifications/read-all | — | @markAllRead | 200 | 13-02 |
+| POST | notifications/{id}/read | `id [0-9]+` | @markRead (HttpException 404 → Winter page) | 404 | 13-02 |
+
+### Credentials (9 JWT, 7 ported) — `AiCredentialController` (176), `OrgAiCredentialController` (153), `DiscogsCredentialController` (175)
+| Method | Path | PHP | Fx | Plan |
+|---|---|---|---|---|
+| GET | ai-credential | @show | 200 `{"configured":false}` | 13-02 |
+| POST | ai-credential | @store | 200 `{"configured":true,"provider":"openai"}` | 13-02 |
+| POST | ai-credential/test | @test (live AI call) | 200 | **pending → Phase 14 (D-02)** |
+| GET | org-ai-credential | @show | 200 | 13-02 |
+| POST | org-ai-credential | @store | 200 | 13-02 |
+| DELETE | org-ai-credential | @destroy | 200 (manifest says 404) | 13-02 |
+| GET | discogs-credential | @show | 200 `{"configured":false,"source":null,"shared":false}` | 13-02 |
+| POST | discogs-credential | @store | 200 `{"configured":true,"source":"user","shared":false}` | 13-02 |
+| POST | discogs-credential/test | @test (live Discogs) | 200 | **pending → Phase 14 (D-02)** |
+
+### Onboarding, invitation inspection (3, public)
+| Method | Path | Group / middleware | PHP | Fx | Plan |
+|---|---|---|---|---|---|
+| GET | onboarding/status | `['bindings','throttle:10,1']` (routes.php:351-356) | OnboardingController@status | 200 | 13-02 |
+| POST | onboarding/bootstrap | same group | OnboardingController@bootstrap (111 lines) | 409 | 13-02 |
+| GET | invitations/{token} | ungrouped, `['bindings','throttle:10,1']`, no constraint (routes.php:361-364) | InvitationApiController@inspect | 200 (manifest says 404, D-15) | 13-02 |
+
+### Wishlist JWT (28, 26 ported) — routes.php:134-225
+| Method | Path | Constraints / throttle | PHP controller@method | Fx | Plan |
+|---|---|---|---|---|---|
+| GET | wishlist/share | — | WishlistShareController@show (142) | 200 | 13-03 |
+| PUT | wishlist/share | — | @update | 200 | 13-03 |
+| POST | wishlist/share/regenerate | `throttle:10,1` | @regenerate | 200 | 13-03 |
+| GET | wishlist/household | — | WishlistHouseholdController@index (121) | 200 | 13-03 |
+| GET | wishlist/subscribers | — | WishlistSubscriptionController@subscribers (166) | 200 | 13-03 |
+| GET | wishlist/settings | — | WishlistSettingsController@show (93) | 200 | 13-03 |
+| PUT | wishlist/settings | — | @update | 200 | 13-03 |
+| GET | wishlist/subscriptions | — | @subscriptions | 200 | 13-03 |
+| GET | wishlist/token/{token}/subscribe | none (string) | WishlistSubscriptionController@statusByToken | 200 | 13-03 |
+| POST | wishlist/token/{token}/subscribe | none | @subscribeByToken (201) | 201 | 13-03 |
+| DELETE | wishlist/token/{token}/subscribe | none | @unsubscribeByToken (404 page) | 404 | 13-03 |
+| GET | wishlist/{collectionId}/subscribe | `collectionId [0-9]+` | @statusByCollection | 404 | 13-03 |
+| POST | wishlist/{collectionId}/subscribe | `collectionId [0-9]+` | @subscribeByCollection (201) | 404 | 13-03 |
+| DELETE | wishlist/{collectionId}/subscribe | `collectionId [0-9]+` | @unsubscribeByCollection | 404 | 13-03 |
+| POST | wishlist/albums/{id}/purchase | `id [0-9]+` | WishlistAlbumApiController@purchase (362) | 404 JSON | 13-03 |
+| POST | wishlist/albums/{id}/reserve | `id [0-9]+` | WishlistAlbumReservationController@reserve (175) → 201 | 404 page | 13-03 |
+| DELETE | wishlist/albums/{id}/reserve | `id [0-9]+` | @cancel | 404 page | 13-03 |
+| POST | wishlist/albums/{id}/reveal | `id [0-9]+` | @reveal | 404 page | 13-03 |
+| GET | wishlist/albums | — | WishlistAlbumApiController@index | 200 | 13-03 |
+| POST | wishlist/albums | — | @store (201, `duplicate` key) | 201 | 13-03 |
+| GET | wishlist/albums/similar | — | @similar (`{"wishlist":[],"collection":[]}`) | 200 | 13-03 |
+| GET | wishlist/albums/{id} | `id [0-9]+` | @show | 404 JSON | 13-03 |
+| PUT | wishlist/albums/{id} | `id [0-9]+` | @update | 404 JSON | 13-03 |
+| DELETE | wishlist/albums/{id} | `id [0-9]+` | @destroy | 404 JSON | 13-03 |
+| POST | wishlist/albums/{id}/match | `id [0-9]+` | WishlistReleaseMatchController@match | 404 | **pending → Phase 14 (D-01)** |
+| POST | wishlist/albums/{id}/apply-release | `id [0-9]+` | @applyRelease | 404 | **pending → Phase 14 (D-01)** |
+| GET | wishlist/{collectionId}/albums | `collectionId [0-9]+` | WishlistPeerAlbumController@index (137) | 404 page | 13-03 |
+| GET | wishlist/{collectionId}/albums/{albumId} | both `[0-9]+` | @show | 404 page | 13-03 |
+
+### Wishlist on the token group (4) — routes.php:507-510
+| Method | Path | Scope | PHP | Fx | Plan |
+|---|---|---|---|---|---|
+| GET | wishlist/albums | `inv.scope:read` | WishlistAlbumApiController@index | 200 | 13-03 |
+| POST | wishlist/albums | `inv.scope:write` | @store | 201 | 13-03 |
+| PUT | wishlist/albums/{id} | `inv.scope:write`, `id [0-9]+` | @update | 404 | 13-03 |
+| DELETE | wishlist/albums/{id} | `inv.scope:write`, `id [0-9]+` | @destroy | 404 | 13-03 |
+
+None of these is mounted on the token group: show, purchase, similar, match, apply-release, reserve, reveal, rating, share, settings, subscriptions or peer albums [VERIFIED: routes.php:501-506 comment and the route list].
+
+### CSV (8) — `CsvImportApiController` (578), `CsvExportApiController` (78)
+| Method | Path | Group | Constraints / throttle | PHP | Fx | Plan |
+|---|---|---|---|---|---|---|
+| POST | import/csv | J | `throttle:10,1` | @store → 202 | 422 `csv_unreadable` | 13-04 |
+| GET | import/csv/{id} | J | `id [0-9]+` | @show | 404 `{"error":"Nie znaleziono importu."}` | 13-04 |
+| PATCH | import/csv/{id}/mapping | J | `id [0-9]+` | @updateMapping | 404 | 13-04 |
+| PATCH | import/csv/{id}/rows/{rowId} | J | `id`, `rowId [0-9]+` | @updateRow | 404 | 13-04 |
+| POST | import/csv/{id}/commit | J | `id [0-9]+` | @commit → 202 | 404 | 13-04 |
+| POST | import/csv/{id}/cancel | J | `id [0-9]+` | @cancel | 404 | 13-04 |
+| GET | export/csv | J | — | CsvExportApiController@download | 200 text/csv | 13-04 |
+| GET | export/csv | T | `inv.scope:read` | same | 200 text/csv | 13-04 |
+
+### Public views (6) — `PublicShareApiController` (403), `PublicShareHeaders` (77)
+The group runs `['bindings', PublicShareHeaders::class]` [routes.php:399-428].
+
+| Method | Path | Route middleware | Where | PHP | Fx | Plan |
+|---|---|---|---|---|---|---|
+| GET | public/{token} | `throttle:10,1` | none on token | @resolve(…,'collection') | 200 | 13-05 |
+| GET | public/{token}/albums | `throttle:fonoteka-public-token`, `throttle:fonoteka-public-ip` | — | @index | 200 | 13-05 |
+| GET | public/{token}/albums/{id} | same two buckets | `id [0-9]+` | @show | 404 `{"error":"Not found"}` | 13-05 |
+| GET | public-wishlist/{token} | `throttle:10,1` | — | @resolve(…,'wishlist') | 200 | 13-05 |
+| GET | public-wishlist/{token}/albums | two buckets | — | @index(…,'wishlist') | 200 | 13-05 |
+| GET | public-wishlist/{token}/albums/{id} | two buckets | `id [0-9]+` | @show(…,'wishlist') | 404 | 13-05 |
+
+**Count check:** the 62 routes are 28 + 4 + 3 wishlist, 4 notifications, 6 + 2 CSV, 9 credentials, 2 onboarding, 1 inspection and 3 public collection routes. CONTEXT's breakdown ("5 personal-token", "CSV 7") moves the token-group export into the wishlist bucket. The total of 62 and the 58/4 split are correct. After this phase the corpus has 157 ported routes (99 + 58), so the parity `expectedPortedRoutes = 99` [VERIFIED: parity/parity_test.go:29] becomes 157. Ten pending routes remain outside Phase 13: `GET /api/v1/fonoteka/me`, two `oauth-identities` routes, five album match/recognize/import routes and two token-group recognize/cover-price routes.
+
+## Per-Area Analysis
+
+### Notifications
+- **PHP.** The controller is above. `index` maps `{id, type, payload, read_at, created_at}` with `toIso8601String`. `markRead` scopes the update by `user_id` and turns zero rows into `HttpException(404)` [VERIFIED: NotificationApiController.php:17-67]. Eloquent-builder `update()` also bumps `updated_at` [ASSUMED].
+- **Go has:**
+ - `models.Notification` with `Payload lagoon.Jsonable[map[string]any]` [VERIFIED: models/notification.go].
+ - `classes.WriteNotification`, which publishes `notification:new` then `notification:count` on the transaction.
+ - The type constants `"album_added"`, `"invitation_accepted"`, `"wishlist_item_added"`, `"wishlist_item_purchased"`, `"reservation_revealed"` [VERIFIED: classes/notification_service.go:23-29].
+- **Missing:** the four handlers. Read the payload as `json.RawMessage` so the PHP key order survives: tide's diff ignores key order, but the claim here is byte compatibility.
+
+### Credentials
+- **PHP behaviour** [VERIFIED: the three controllers]:
+ - `show` returns `{configured:false}` or `{configured,provider,model,base_url}`.
+ - `store` validates `provider: required|in:claude,openai`, `api_key: nullable|string`, `model: nullable|string|max:255`, `base_url: nullable|url`. A missing `api_key` reuses the stored key, otherwise it throws `ValidationException::withMessages(['api_key' => ['The api key field is required.']])`.
+ - `updateOrCreate` writes only the validated keys present in the request (absent ≠ null).
+- **Org store quirk:** the reused key comes from the caller's **`UserAiCredential`**, not from the org credential [VERIFIED: OrgAiCredentialController.php store]. Port it as is.
+- **Org guards:** no organisation → 404 `{"error":"No organisation"}` on show and destroy. Store provisions an organisation (`OrgProvisioner::provisionFor`), then `canManage` → 403 `{"error":"Forbidden"}`.
+- **Discogs:**
+ - The token is trimmed. The regex is `/^[A-Za-z0-9_\-]{10,255}$/`, with a custom message `golem15.fonoteka::lang.discogs.token_invalid_format`.
+ - `shared && !canManage` → 403 with `discogs.shared_forbidden`.
+ - The user and org writes happen in one transaction.
+ - `status()` = `{configured: DiscogsGate::allows, source: resolver source, shared: org credential exists}`.
+- **Go has:**
+ - The four credential models with `lagoon.Encrypted` and `json:"-"`.
+ - `CredentialFillFields = []string{"provider", "model", "base_url"}` [VERIFIED: classes/credential_write_service.go:16] and its fuzz test.
+ - `IsSiteAdmin`, `CanManageOrg`, `AIAllowed`, `ResolveDiscogsConfig` and `DiscogsAllowed` [VERIFIED: classes/gates.go].
+ - `ProvisionOrgFor` [VERIFIED: classes/org_provisioner.go:28].
+ - The config keys `golem15.fonoteka.ai_org_lock` and `golem15.fonoteka.discogs.token` [VERIFIED: gates.go consts; config/config.yaml].
+- **Missing:**
+ - The seven handlers.
+ - The Discogs lang strings for `shared_forbidden` and `token_invalid_format`.
+ - The `AiConfigResolver` port (PHP AiConfigResolver.php, 72 lines). Its only callers are `/test` and recognize (Phase 14), so it is optional here. D-02's resolution order is fully observable in Phase 13 through `AIAllowed`/`me/context` and Discogs `status()`.
+ - `AIAllowed` does not have PHP `AiGate`'s site-admin branch (`Settings::getVisionModel() !== null`) [VERIFIED: AiGate.php vs gates.go:59-76]. Leave it for Phase 14 unless a recorded case needs it.
+- **Ciphertext compatibility:** PHP casts `'api_key' => 'encrypted'` (Laravel's AES-CBC envelope) [VERIFIED: models/UserAiCredential.php]. Go uses AES-256-GCM with its own format byte [VERIFIED: lagoon/encrypted.go]. Existing PHP ciphertext is unreadable to Go. This is a Phase 15 data-migration concern, not an API concern.
+
+### Onboarding, invitation inspection and the register hook (D-09..D-11)
+
+**PHP bootstrap** [VERIFIED: OnboardingController.php:43-110]:
+- `status` = `{"needs_onboarding": User::count() === 0}`.
+- `bootstrap`:
+ - A count before the lock → 409 `{"error":"Onboarding already completed"}`.
+ - `validate(['org_name' => 'required|string|max:255', 'email' => 'required|email|unique:users', 'password' => 'required|min:8'])`, which fails as a 500 page (Finding 7).
+ - `Cache::lock('fonoteka:onboarding:bootstrap', 10)->block(5, ...)` wraps `DB::transaction`.
+ - Inside the transaction: a recount, `Organisation::create(['name' => ..., 'slug' => Str::slug(org_name)])`, `Auth::register($reg, true, false)`, `organisation_id` plus `organisation_role = 'owner'`, `Event::fire('golem15.user.register', [$user, $reg])`, `JWTAuth::attempt`, then `{token, user: getApiArray()}`.
+ - A lock timeout → 409.
+
+**PHP inspection:** `inspect` → `{"data":{"state":"unavailable"}}` or `{"data":{"state":"pending","collection_name":...}}` [VERIFIED: InvitationService.php:26-37].
+
+**PHP register listener** [VERIFIED: Plugin.php:180-205]: hashes `invitation_token` with sha256 and matches `token_hash`, `accepted_at IS NULL`, `revoked_at IS NULL`, `expires_at > now()` and `LOWER(email) = strtolower(trim(user.email))`. A match → `PendingInvitationRegistration::updateOrCreate(['user_id'], ['invitation_id'])`. Otherwise → `CollectionProvisioner::provision($user)`.
+
+**Go user plugin today** [VERIFIED: plugins/golem15/user/classes/events.go:19-22]:
+
+```go
+// RegisterEvent is fired after a user row is created. No listener consumes it yet.
+type RegisterEvent struct {
+ User *models.User
+}
+```
+
+`controllers.Register` handles everything inline:
+- validation, then `bouncer.HashPassword`, then `fields["password"] = hashed`, then `lagoon.Fill`, then `Create`;
+- `_ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user})` — the error is ignored and there is no transaction [VERIFIED: user/controllers/api_controller.go:602-703, line 671];
+- then the unexported `mintFor` and `apiArray` [api_controller.go:813-852].
+
+Nothing reusable is exported for D-11.
+
+**Additive exports to recommend in sm-user-plugin:**
+- `RegisterEvent.Payload map[string]any` (D-09). Fill it with a copy of the request fields **with `password` and `password_confirmation` removed**. By the time the event fires, `fields["password"]` holds the bcrypt hash and `password_confirmation` still holds the plaintext. Removing both keeps secrets out of every listener. This deviates from "raw input" and must be confirmed (Assumption A3).
+- An exported registration core, `RegisterUser(ctx, app, db, fields, opts)`: validate, hash, fill, create, fire `RegisterEvent`. `/register` and the bootstrap share it.
+- Exported `IssueToken` (mintFor) and `APIArray` (apiArray).
+- The README updated in the same commit.
+
+Do not change `/register`'s behaviour or body. The Go user model has `Organisation` [VERIFIED: user/models/organisation.go].
+
+**Go fonoteka has:**
+- `guardPendingInvitation` (consume side) [VERIFIED: classes/active_collection.go:269].
+- `ProvisionCollection(ctx, tx, user)` [VERIFIED: classes/collection_provisioner.go:26].
+- `invitationTokenHash` [VERIFIED: classes/invitation_service.go:115-118].
+- A unique `user_id` on `pending_invitation_registrations` (`CONSTRAINT fonoteka_pending_invite_user_unique UNIQUE (user_id)`) [VERIFIED: updates/20_remaining.go:58]. `ON CONFLICT (user_id) DO UPDATE` is therefore the right upsert.
+- `models.Slug` [VERIFIED: models/slug.go:21]. Check it against `Str::slug` for org names.
+
+**Missing:** the listener in `Boot` (pattern: `app.Events.Listen[*userclasses.GetApiArrayEvent]` at plugin.go:75), the onboarding handlers, the inspect handler and `InspectInvitation`.
+
+**Race guard:** use `SELECT pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap'))` inside the transaction, plus the in-transaction `COUNT(*) FROM users WHERE deleted_at IS NULL`, instead of a cache lock. A loser blocks, then sees count > 0 → 409.
+
+### Wishlist (30 routes)
+
+**PHP services** [VERIFIED: each file]:
+
+| Service | Lines | Responsibility |
+|---|---|---|
+| `ActiveWishlistResolver` | 35 | Own `kind='wishlist'` collection or provision |
+| `WishlistProvisioner` | 49 | `FOR UPDATE` on the user row then the existing wishlist; creates `name 'Moja lista życzeń'` |
+| `WishlistSubscriptionService` | 247 | `subscribe`, `unsubscribe`, `subscribersOf`, `followerCountOf`, `stateFor`, `subscriptionsFor` |
+| `AlbumReservationService` | 187 | `reservableWishlistIds`, `reserve`, `cancel`, `reveal`, `releaseForAlbum`, `stateForViewer` |
+| `CollectionShareService::resolvePublic` / `stateFor` | 236 | Token lookup and share state |
+| `NotificationService` | 284 | `notifyWishlistItemAdded`, `notifyWishlistItemPurchased`, `notifyReservationRevealed`, `mailWishlistPurchased` |
+| `AlbumSimilarityFinder` | 67 | Similar-album lookup |
+| `AlbumWriteService::moveToCollection` | — | The purchase move |
+
+Service details:
+- `WishlistSubscriptionService`:
+ - `subscribe` is `updateOrCreate` and sets `subscribed_at = now()`.
+ - `subscribersOf` adds synthetic household peers with `ws_enabled` and `email_enabled` true.
+ - `followerCountOf` counts the union of peers and subscribers, excluding the owner.
+ - `stateFor` carries `forced` for household peers.
+ - `subscriptionsFor` lists household wishlists sorted by owner name, then other subscriptions.
+- `AlbumReservationService`:
+ - `reserve` takes `FOR UPDATE` on the album, checks for an existing reservation (→ `HttpException(409)`), then creates.
+ - `reveal` is idempotent, writes `revealed_at` and the `reservation_revealed` bell notification only, no mail.
+ - `releaseForAlbum` deletes and returns the reserver id.
+ - `stateForViewer` hides `reserved_by` from the owner before reveal.
+- `CollectionShareService::resolvePublic`: the 16-character regex, `whereRaw('LOWER(public_token) = ?')`, `public_enabled`, `kind`, then `hash_equals`. `stateFor` builds the `/w/` or `/k/` path.
+- `AlbumSimilarityFinder`: name and artist `LIKE` with `addcslashes('\\%_')`, an optional year, the `photos` embed, `orderBy('name')`, limit 6.
+- `AlbumWriteService::moveToCollection`: sets `collection_id` and saves, then `releaseForAlbum`, then `notifyWishlistItemPurchased($album, $from, $reserverUserId)` [AlbumWriteService.php:270-282].
+
+**Scopes:**
+- `Collection::wishlistsVisibleTo($user)` = `kind='wishlist' AND owner_id IN` the owners and editors of every collection the user is a member of, the user included [VERIFIED: models/Collection.php:247-264].
+- `accessibleBy` already exempts the owner's wishlist from a token pin. Go `AccessibleBy` mirrors this [VERIFIED: classes/access.go:36-48].
+
+**Go has:**
+- The models `WishlistSubscription`, `WishlistDigestQueue`, `AlbumReservation` and `Collection`. The unique constraints are `UNIQUE (album_id)` on reservations, `UNIQUE (user_id, collection_id)` on subscriptions and on the digest queue [VERIFIED: updates/10_album_slice.go:216, 20_remaining.go:157, 179].
+- `ShareStateFor`, `EnableShare`, `DisableShare`, `RegenerateShare`, `RenameShare` and the `/w/` path [VERIFIED: classes/share_service.go:72-140].
+- `CreateAlbum`, `UpdateAlbum`, `FindDuplicateAlbum`, `SerializeDuplicate`, the cover importer and the album broadcast and search sync hooks.
+- `AlbumsAccessibleBy` and `NotificationActor`.
+- `albumAddedCallback`, which today returns early for any non-real collection (`if col.Kind != kindRealCollection { return nil }`) [VERIFIED: classes/notification_service.go:248-250].
+
+**Missing:**
+- A wishlist resolver and provisioner. Nothing in Go matches `Moja lista`.
+- A `wishlistsVisibleTo` scope and the reservable ids.
+- The subscription service.
+- The reservation service and the reservation DTO. `AlbumDTO` has no `reservation` key; add `Reservation *ReservationDTO \`json:"reservation,omitempty"\``, whose conditional keys are omitted, not nulled.
+- The similarity finder. Use `ILIKE` with escaping: Postgres `LIKE` is case-sensitive, while the recording ran on SQLite and production runs on MariaDB `_ci`.
+- `prohibited` rules.
+- The household preview: 8 albums with `orderBy('name')`.
+- The purchase move and its side effects, and the purchase mail job with its templates. PHP has `wishlist_item_purchased(.htm|-en.htm)` with vars `albumName` and `wishlistName`, subject `"Pozycja na liście życzeń została kupiona"` / `"A wishlist item has been purchased"` and layout `plytarium` [VERIFIED: views/mail/wishlist_item_purchased*.htm]. Go `mail.go` registers only the invitation templates [VERIFIED: mail.go:16-29].
+- The item-added branch with the digest upsert.
+
+**Recommendation for the item-added discretion:** extend `albumAddedCallback` rather than adding an explicit call.
+- The callback already fires for every album insert (JWT, token group, bulk, and the Phase 14 CSV path) on the insert's transaction. The `kind == wishlist` branch then calls a new `NotifyWishlistItemAdded`.
+- Wire a job queue through an atomic pointer, the way `realtimeService` is wired [notification_service.go:204-209].
+- GORM's default transaction is on (no `SkipDefaultTransaction` anywhere in `modules/` [VERIFIED: grep]), so `conga.Dispatch` sees a `*sql.Tx` and joins it [VERIFIED: conga.go:494-500].
+- Publications go out after commit through `lighthouse.Emit` on the transaction.
+
+**Digest upsert:** use `INSERT ... ON CONFLICT (user_id, collection_id) DO UPDATE SET item_count = golem15_fonoteka_wishlist_digest_queue.item_count + 1, updated_at = NOW() RETURNING (xmax = 0) AS inserted`. Dispatch the digest job only when `inserted`. This is atomic, while PHP's `firstOrNew` + `save` is racy.
+
+### CSV import and export
+
+**PHP** [VERIFIED: CsvImportApiController.php]:
+- `store`:
+ - A missing or invalid file, or an extension other than csv/txt → `csv_unreadable` 422. Size over `MAX_BYTES = 5242880` → `csv_too_large`.
+ - `CsvAlbumParser::parse`. A `CsvParseException` → error with `$e->errorCode` **and the English exception message** (e.g. `"CSV file is unreadable."`), not the translated one.
+ - Invalid canonical rows → `validation_failed`.
+ - Resolve the active collection, store to `fonoteka-csv//.csv` on the private disk, create the import with status `preview` (canonical) or `mapping`, `replaceRows`, then 202.
+- `show`: rows paginated by `page` and `per_page`, `max(1, min(50))`.
+- `updateMapping`:
+ - Requires `isBeforeCommit` (mapping, preview or failed), else `csv_already_committed`.
+ - Reads `column_map` (or the whole body), re-parses, cancels the old match job and replaces the rows.
+ - Sets status `preview` (canonical) or `uploaded`. When not canonical, it dispatches the match job and sets `match_job_id`.
+- `updateRow`:
+ - `skip` / `accept_csv` change the row status only.
+ - The `selected_discogs_id` path validates a digit string that must be one of the row's `candidates_json[*].discogs_id`, then `DiscogsGate::allows` (else `discogs_unavailable`), then the inline `getRelease`.
+- `commit`: compare-and-swap `preview` → `importing`. A replay when the status is done or importing → 202 with the current job. Otherwise `csv_match_not_ready`. On success it dispatches the import job and returns 202 `{import_job_id, status, import_mode}`.
+- `cancel`: cancels both jobs and sets status `canceled`.
+- `serializeImport`: `summary` comes from `pluck('aggregate','status')`, and an empty result encodes as `[]` (a PHP empty array) [ASSUMED]. `progress` comes from the row counts (matching, uploaded, mapping) or from the job row.
+
+**Constants and strings:**
+- Statuses: `'uploaded'`, `'mapping'`, `'matching'`, `'preview'`, `'importing'`, `'done'`, `'failed'`, `'canceled'`. Modes: `'fill_empty'`, `'overwrite'`. Row statuses: `'pending'`, `'matched'`, `'matched_ambiguous'`, `'matched_csv'`, `'resolved'`, `'written'`, `'skipped'`, `'error'` [VERIFIED: models/CsvImport.php:21-36, CsvImportRow.php:14-21].
+- Polish messages [VERIFIED: lang/pl/lang.php:138-147]:
+ - `'not_found' => 'Nie znaleziono importu.'`
+ - `'csv_unreadable' => 'Nie można odczytać pliku CSV.'`
+ - `'csv_too_large' => 'Plik CSV jest za duży.'`
+ - `'csv_already_committed' => 'Ten import został już zatwierdzony.'`
+ - `'csv_match_not_ready' => 'Dopasowanie jeszcze się nie skończyło.'`
+ - `'validation_failed' => 'Niepoprawne dane importu.'`
+ - `'discogs_rate_limited' => 'Limit zapytań do Discogs został wyczerpany. Spróbuj za chwilę.'`
+ - `'discogs_unavailable' => 'Nie udało się pobrać tego wydania z Discogs.'`
+
+**Parser classes to port** (`classes/csv/`, about 1,068 lines) [VERIFIED: wc]:
+
+| Class | Lines | Notes |
+|---|---|---|
+| `CsvAlbumParser` | 409 | `fgetcsv(..., '"', '')` logical records, `MAX_ROWS = 5000`, BOM strip, UTF-8, then Windows-1250, then ISO-8859-2 decode, `\p{L}` check |
+| `CsvColumnDetector` | 277 | Polish/English header aliases, delimiter guess, combined `Artist - Title` |
+| `CsvAlbumContract` | 213 | `HEADERS`, `exportRow`, `parseRow`, tracklist codec, `FORMULA_PATTERN = '/^[\x09\x0A\x0D ]*[=+\-@]/'` formula guard |
+| `CsvPipeCodec` | 68 | Pipe codec |
+| `CsvColumnMapper` | 50 | Column mapping |
+| `CsvCanonicalIdMatcher` | 34 | Canonical id matching |
+| `CsvParseException` | 17 | Exception type |
+
+The Windows-1250 and ISO-8859-2 decoding needs `golang.org/x/text/encoding/charmap`. `x/text` is already in the tree through go-i18n (CLAUDE.md version table), but importing it directly is a new direct dependency. Name it at the plan-count checkpoint, or hand-port the two 128-entry tables (Assumption A5).
+
+**Export** [VERIFIED: CsvExportApiController.php]:
+- Validates the filters, then `authenticatedDatabaseQuery`, which is the SQL path only.
+- Writes the BOM `"\xEF\xBB\xBF"`, the `HEADERS` row, then rows streamed with `lazy(100)`.
+- `Content-Type: text/csv; charset=UTF-8`.
+- The filename is `plytarium-kolekcja-.csv`.
+
+**Go has** the models `CsvImport` and `CsvImportRow` (with `MatchJobID` and `ImportJobID *uint`, and `ColumnMap` as a Jsonable map) [VERIFIED: models/csv_import*.go], `searchSQL` and the filters (unexported) [VERIFIED: classes/album_search.go:317], `conga.Dispatch`/`CancelJob`, and tide multipart support.
+
+**Missing:** everything above, plus a **private** blob bucket config for uploads. The only bucket today is `bucket_url: "file://./storage/app/uploads/public"`, which is served publicly [VERIFIED: config/storage.yaml]. Add one, for example `golem15.fonoteka.csv.bucket_url` with default `file://./storage/app`.
+
+**D-05 interim design (recommended):** port the whole `selected_discogs_id` branch up to the `getRelease` call, then call a `ReleaseFetcher` interface. The Phase 13 implementation always returns an error, which maps to PHP's own 422 `{"result":"error","code":"discogs_unavailable","message":"Nie udało się pobrać tego wydania z Discogs."}`.
+- This is exactly what PHP answers when Discogs is unreachable, and it fabricates nothing.
+- In Phase 13 no code path writes `candidates_json` (only the Phase 14 match job does), so every valid request already ends in 422 `validation_failed` at the candidate allow-list. The seam is only reachable with rows that came from a PHP database.
+- The successful-pick recorded case stays pending for Phase 14. The `skip`, `accept_csv`, `validation_failed` and `discogs_unavailable` (gate off) cases are recorded now.
+
+## Jobs (kinds, args, queues, labels)
+
+Go's established pattern [VERIFIED: classes/invitation_service.go:22-61, jobs.go:30-36]: `InvitationMailKind = "golem15.fonoteka.invitation_mail"`, `InvitationMailQueue = "mail"`, `InvitationMailAttempts = 3`, args `{"invitation_id","token"}`. It is registered with `conga.Job(p.sendInvitationMail, conga.OnQueue(...), conga.MaxAttempts(...))` and inserted with `Enqueue`, which writes no `summer_jobs` row.
+
+| Job | PHP | Recommended Go kind | Args (JSON) | Insert | Queue | Label / delay / count / metadata | Worker |
+|---|---|---|---|---|---|---|---|
+| CSV import | `new AlbumCsvImportJob((int) $import->id)` via `JobManager::dispatch` | `golem15.fonoteka.csv_import` | `{"csv_import_id":N}` | `conga.Dispatch` on the commit tx | `fonoteka.csv.import` (D-03; must stay unserved, Finding 2) | `fonoteka.csv.import` / 0 / `row_count` / `{"csv_import_id":N}` | Phase 14 |
+| CSV match | `new AlbumCsvMatchJob((int) $import->id)` | `golem15.fonoteka.csv_match` | `{"csv_import_id":N}` | `conga.Dispatch` on the mapping tx | `fonoteka.csv.match` (unserved) | `fonoteka.csv.match` / 0 / `row_count` / `{"csv_import_id":N}` | Phase 14 (240 s timeout; re-dispatch with the same label and extra metadata `resumed_after_rate_limit`, `retry_after` [VERIFIED: AlbumCsvMatchJob.php:189-201]) |
+| Wishlist digest | `new WishlistDigestJob($userId, $collectionId)`, `'wishlist_digest', [], 1800` | `golem15.fonoteka.wishlist_digest` | `{"subscriber_id":U,"wishlist_collection_id":C}` (PHP ctor names) | `conga.Dispatch` on the item-add tx | `fonoteka.wishlist.digest` (unserved until Phase 14) | `wishlist_digest` / `Delay: 1800*time.Second` / 0 / nil (stores `""`) | Phase 14 |
+| Purchase mail | inline `Mail::send` (D-07 changes this) | `golem15.fonoteka.wishlist_purchased_mail` | `{"subscriber_id":U,"album_name":..., "wishlist_name":...}`; no secrets, names snapshotted as the inline send saw them | `conga.Enqueue` on the purchase tx (no `summer_jobs` row, like invitation mail) | `mail` | — | **Phase 13** (registered in `Jobs()`) |
+
+Kind names follow the existing `golem15.fonoteka.` convention and are stable from this phase on (D-03 reversibility). The `summer_jobs` row is what `show`, `cancel` and Phase 14's progress read, so CSV and digest must use `Dispatch`, not `Enqueue`.
+
+## Parity Harness Notes
+
+**How recording works:**
+- The isolated PHP runs on 127.0.0.1:8423 (`php_parity.sh reset|serve|serve-mail|artisan`) over SQLite under `$PARITY_ROOT`. The vars live in a 0600 file outside git.
+- `fonoteka_reset.php` with `PARITY_CASE=` rebuilds alice, bob, outsider and newbie, the org, collections, the wishlist and pinned tokens. It also lifts the sequences to 8-digit ranges.
+- `summer parity:record --spec --output --vars ... --rules parity/capture-rules.yaml` records one fixture.
+- `seedFonotekaCase(ctx, db, store, extra)` mirrors each state in Go [VERIFIED: parity/README.md "Phase 12 collections/household/albums recording"; fonoteka_seed_test.go:60-115].
+
+**New states needed** in both `fonoteka_reset.php` and `seedFonotekaCase`:
+- `wishlist`: alice's wishlist with items, share enabled, `reservations_allowed`, bob subscribed, bob holding a reservation, notifications rows.
+- `csv`: imports in each status, with rows and a job row.
+- `credentials`: user and org credentials.
+- `empty`: zero users, for onboarding. Use `isolatedFlowDB` [fonoteka_flows_test.go:39].
+- `invite-for-register`: a pending invitation for a new email.
+
+Captured share tokens are `share:wishlist` and `share:collection`. Add `share:wishlist` to `capture-rules.yaml` for `PUT/POST wishlist/share*`. Today only the collection share routes carry a capture rule [VERIFIED: capture-rules.yaml:108-121]; the manifest carries `as: share:wishlist` per case (manifest.yaml:586-590).
+
+**Flows:** add `TestFonotekaNuxtFlows/nuxt-wishlist` and `/nuxt-csv`, a public-anonymous flow, an onboarding flow and the MCP `mcp-wishlist` flow, each on its own database like `nuxt-albums` [fonoteka_flows_test.go:21-60]. `nuxt-csv` uploads through tide multipart `parts` (pinned by sha256).
+
+**Publications:** record `notification:new` / `notification:count` with `parity:broadcasts`. This needs the normalizer extension from Finding 8.
+
+**Throttle state across cases:**
+- Laravel's inline `throttle:10,1` key for guests is `domain|ip` and is shared by **every** inline-throttled route. surf mirrors that with `"inline:domainless|" + ClientIP(...)` [VERIFIED: surf/limiter.go:168-178].
+- So onboarding, invitation inspection and the public resolves draw from one 10/min budget per IP.
+- In Go a fresh target is built per route [parity_test.go:225-230], but cases within a route share the limiter. Keep at most 10 anonymous inline-throttled cases per route, and record the anti-enumeration sequence as its own flow.
+- On the PHP side `CACHE_DRIVER=file` persists limiter state, so run `php_parity.sh reset` (which runs `cache:clear`) before each anonymous recording.
+
+**The pubfail sequence:** record 10 × `GET public//albums` (404 `{"error":"Not found"}`), then the 11th → 429 `{"error":"Too many requests"}` from the controller check. Use the albums route because it has no `throttle:10,1`. On resolve routes, the inline limiter fires at the 11th request first, with the same body and different headers.
+
+## Rate Limits (D-14)
+
+Today: `r.Group("/_fonoteka/api/v1", surf.Use("public.share-headers", "throttle:fonoteka-public-token", "throttle:fonoteka-public-ip"), func(g pact.Router) {})` [VERIFIED: routes.go:212]. The buckets `"fonoteka-public-token"` (60/min, key `"pubtok:" + r.PathValue("token")`) and `"fonoteka-public-ip"` (120/min, `surf.ClientIP`) are already registered [VERIFIED: plugin.go:307-319].
+
+Change to:
+- the group with `surf.Use("public.share-headers")` only;
+- `g.Get("/public/{token}", resolve, "throttle:10,1")`;
+- `g.Get("/public/{token}/albums", idx, "throttle:fonoteka-public-token", "throttle:fonoteka-public-ip")`;
+- the same for `albums/{id}` (plus `g.Where("id","[0-9]+")`);
+- mirror all three for `public-wishlist`.
+
+Route middleware composes inside the group middleware [VERIFIED: surf/router.go:330-339, 369-412], so `PublicShareHeaders` still rewrites the limiter's 429 into JSON.
+
+Fill the `onboarding` group (`throttle:10,1`, routes.go:208) and the `public_invitation` group (routes.go:210) as they are.
+
+**The pubfail counter:** surf's `Store` has only `Attempt` (hit-and-check) [VERIFIED: surf/limiter_store.go:11-13]. PHP needs a check that does not count (`RateLimiter::tooManyAttempts(key, 10)`) and a separate count on failure (`RateLimiter::hit(key, 60)`) [VERIFIED: PublicShareApiController.php:361-397]. Add a small app-level counter keyed `pubfail:` (the trusted-proxy client IP) with a fixed 60 s window that starts at the first hit, plus `TooMany(key, 10)` and `Hit(key)`.
+
+## Standard Stack
+
+### Core (all already in the tree; no new framework choices)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Go stdlib `net/http`, `encoding/json`, `crypto/subtle`, `crypto/sha256` | go1.27.0 | Handlers, constant-time token compare, invitation hash | Project constraint |
+| GORM + `gorm.io/driver/postgres` (pgx) | in go.mod | Models, scopes, `ON CONFLICT`, `FOR UPDATE` | Decided |
+| River v0.47.0 via `conga` | `github.com/riverqueue/river v0.47.0` [VERIFIED: summercms.go/go.mod:23] | Purchase mail, CSV and digest jobs | Decided |
+| `lagoon` (validator, `Encrypted`, `Jsonable`, `Fill`) | framework | Request rules, encryption at rest | Decided |
+| `lighthouse` | framework | Notification publications after commit | Decided |
+| `postcard` | framework | Purchase mail templates, memory driver in tests | Decided |
+| `gocloud.dev/blob` | v0.46.0 | Private CSV upload bucket | Decided |
+| `tide` | framework | Recording, replay, multipart, publication goldens | Decided |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `golang.org/x/text/encoding/charmap` | transitive today (go-i18n) | Windows-1250 / ISO-8859-2 CSV decode | Only if the user approves a direct import (A5); otherwise hand-port the two code pages |
+| `testcontainers-go` + testify | in go.mod | Postgres integration tests | Existing |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| A hand-ported `fputcsv` writer | `encoding/csv.Writer` | Wrong quoting (Finding 6) |
+| `csv.Reader{LazyQuotes:true}` | A hand-ported `fgetcsv` | The Reader is fine if a PHP truth table is green; otherwise port `fgetcsv` |
+| A Postgres advisory lock | A cache lock | No shared cache in the Go stack; the advisory lock is transaction-scoped and replay-safe |
+| A constraint-aware dispatch in `surf` | Renaming routes / one app-level dispatcher | Breaks C-01 |
+
+**Installation:** none. Phase 13 adds no module unless A5 is approved.
+
+## Package Legitimacy Audit
+
+No new external packages are installed in this phase. The only candidate, `golang.org/x/text`, is already a transitive dependency of the approved go-i18n and is a Go-team module. Promoting it to a direct import is a dependency-policy decision for the user (A5), not a legitimacy question.
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| (none new) | — | — | — | — | — | — |
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ ┌────────────── Nuxt SPA / fonoteka-mcp / anonymous browser ──────────────┐
+ │ │
+ JWT /_fonoteka/api/v1 token /api/v1/fonoteka public (no auth)
+ │ │ │
+ surf router (ServeMux + constraint-aware dispatch for overlapping patterns, Finding 1)
+ │ │ │
+ jwt.auth → locale → must-change-pw inv_token → throttle:api-token public.share-headers ─┐
+ │ → inv.scope:read|write │ │
+ │ │ resolve: throttle:10,1 albums: pubtok + pub-ip buckets
+ ▼ ▼ ▼ │
+ ┌───────── handlers (one per route, shared across groups) ─────────┐ PublicShare handlers ─ pubfail counter (peek/hit)
+ │ notifications │ credentials │ wishlist │ CSV │ export │ onboarding│ │
+ └──────┬────────────┬─────────────┬────────┬──────┬──────────┬──────┘ │
+ │ │ │ │ │ │ │
+ tenancy: ActiveCollection / ActiveWishlist resolver, AccessibleBy, wishlistsVisibleTo, reservableWishlistIds
+ │ │ │ │ │ │ │
+ ▼ ▼ ▼ ▼ ▼ ▼ ▼
+ ┌──────────────────────── one GORM transaction per write ─────────────────────────────┐
+ │ rows (notifications, subscriptions, reservations, digest queue, csv_imports/rows, │
+ │ credentials [lagoon.Encrypted], users/org via user plugin RegisterUser) │
+ │ ├─ album insert → GORM callback → album_added | wishlist_item_added + digest upsert│
+ │ ├─ lighthouse.Emit (notification:new/count, album updated) ── published after commit ─► Centrifugo
+ │ ├─ conga.Enqueue purchase mail (queue "mail", worker in Phase 13) ──────────────────► postcard
+ │ └─ conga.Dispatch csv_import / csv_match / wishlist_digest (summer_jobs row + River job,
+ │ unserved queues, no worker until Phase 14) │
+ └────────────────────────────────────────────────────────────────────────────────────┘
+ CSV store → private blob bucket (fonoteka-csv//.csv); export → streamed text/csv (fputcsv port, BOM)
+ user plugin /register ─ RegisterEvent{User, Payload} ─► fonoteka listener → PendingInvitationRegistration | ProvisionCollection
+```
+
+### Recommended Project Structure (fonoteka.go/plugins/golem15/fonoteka)
+```
+classes/
+├── wishlist_resolver.go # ActiveWishlist + provisioner, wishlistsVisibleTo, reservable ids
+├── wishlist_subscriptions.go # subscribe/unsubscribe/subscribersOf/followerCount/stateFor/subscriptionsFor
+├── reservations.go # reserve/cancel/reveal/releaseForAlbum/stateForViewer
+├── wishlist_notifications.go # item-added (+digest upsert/dispatch), purchased, revealed
+├── similarity_finder.go
+├── public_share.go # ResolvePublic, facets, pubfail counter
+├── csv/ # parser, detector, contract, pipe codec, mapper, matcher, fputcsv writer
+├── csv_import_service.go # store/map/row/commit/cancel + job dispatch, ReleaseFetcher seam (D-05)
+├── onboarding.go # bootstrap (advisory lock) + register listener (D-10)
+└── jobs_phase13.go # args types + kind/queue/label constants
+controllers/api/
+├── notifications_controller.go credentials_controller.go onboarding_controller.go
+├── wishlist_albums_controller.go wishlist_share_settings_controller.go
+├── wishlist_subscriptions_controller.go wishlist_reservations_controller.go wishlist_peer_controller.go
+├── csv_import_controller.go csv_export_controller.go public_share_controller.go
+views/mail/wishlist_item_purchased.htm, wishlist_item_purchased-en.htm
+```
+
+### Pattern 1: Constraint-disjoint overlapping routes (framework)
+**What:** one ServeMux pattern per overlapping family, dispatched in registration order with literal and regex checks.
+**When:** only when `mux.Handle` would panic. Non-overlapping routes keep their own patterns.
+
+### Pattern 2: Side effects in the write transaction
+**What:** rows, `lighthouse.Emit` and `conga.Enqueue/Dispatch` all run on the same `tx`. Publications and mails leave only after commit. This is the P11 D-06 and P12 D-13 pattern, already in use: `WriteNotification` emits on `db` [notification_service.go:122-142], and invitation mail is enqueued on `tx` [invitation_service.go:141-157].
+
+### Pattern 3: An optional reservation key in AlbumDTO
+**What:** `SerializeAlbum` gains an optional viewer context. With no context there is no `reservation` key, which keeps every existing caller byte-identical [PHP SerializesFonoteka.php:55-112].
+
+### Anti-Patterns to Avoid
+- **Using `encoding/csv.Writer` for the export:** wrong quoting (Finding 6).
+- **Putting a CSV or digest job on `default` or `mail` before its worker exists:** the job is consumed and discarded (Finding 2).
+- **Read-then-insert for the digest queue:** use `ON CONFLICT ... RETURNING (xmax = 0)`.
+- **Mounting reserve, purchase, share, settings or peer routes on the token group:** PHP excludes them (routes.php:501-506).
+- **Returning credential secrets:** responses carry `provider`, `model` and `base_url` only. The models are already `json:"-"`.
+- **Storing CSV uploads under `storage/app/uploads/public`:** that path is web-served.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Overlapping route dispatch | A per-handler path parser | A `surf` framework feature (Finding 1) | One place, and the route table stays truthful |
+| Job rows and cancellation | Custom job tables | `conga.Dispatch`, `conga.CancelJob`, `summer_jobs` | Same columns and statuses as Apparatus |
+| Encryption at rest | Custom AES | `lagoon.Encrypted` | Already decided, redacting marshal |
+| Constant-time token compare | `==` | `subtle.ConstantTimeCompare` | C-06 |
+| The bootstrap race | An in-memory mutex | `pg_advisory_xact_lock` + an in-tx count | Survives multiple processes |
+| Digest coalescing | SELECT then INSERT | `ON CONFLICT DO UPDATE ... RETURNING (xmax = 0)` | Atomic first-in-window detection |
+| Winter error pages | Inline HTML | `writeWinterHTTPError` (404/409/410/500 pages exist) | Byte-exact recorded pages |
+| Laravel validation | Ad-hoc checks | `lagoon.ValidateRequest` (+ `prohibited`) | Messages and order are part of the contract |
+| Mail | Direct SMTP | `conga` job + `postcard` | D-07, test memory driver |
+
+**Key insight:** almost every primitive already exists. This phase's risk lies in the edges: route overlap, unregistered job kinds, recording-environment drift and byte details.
+
+## Runtime State Inventory
+
+Not a rename or refactor phase. Omitted, except for one runtime note: `river_job` rows of the new kinds will **wait in the live database** until Phase 14 ships workers. That is intended (D-04). The kind and args contract makes these rows the costly-to-reverse state named in D-03.
+
+## Common Pitfalls
+
+### Pitfall 1: ServeMux conflicts only surface at boot
+**What goes wrong:** a test that never assembles the full router passes, while `serve` fails.
+**How to avoid:** the route-table test assembles the real `Routes()` and asserts no error, then dispatches all four overlap pairs with numeric and literal segments.
+
+### Pitfall 2: Tests use the insert-only River client
+**What goes wrong:** with no worker running, River does no unknown-kind check, so every test passes and production 500s (Finding 2).
+**How to avoid:** a conga test that starts a worker and then Dispatches an unregistered kind. An app smoke test that does the same for commit, mapping and item-add.
+
+### Pitfall 3: `summary` empty array and `column_map` key order
+`pluck('aggregate','status')->all()` on no rows is `[]`; with rows it is an object. Go must emit `[]` for none (C-03) [ASSUMED serialization]. tide's diff ignores key order, but a map-typed field re-encodes alphabetically. Keep `column_map` raw if Nuxt depends on its order.
+
+### Pitfall 4: CSV parse error messages are English
+`$this->error($e->errorCode, 422, $e->getMessage())` returns the exception's English text. A missing file uses the translated Polish text. Record both kinds.
+
+### Pitfall 5: `Validator::make` vs `$request->validate`
+The former gives a 422 JSON body, the latter a 500 page (Finding 7). Choose per controller exactly as PHP does.
+
+### Pitfall 6: The public `Cache-Control` string order
+`no-store, private`, exactly (Finding 6).
+
+### Pitfall 7: Postgres `LIKE` is case-sensitive
+`AlbumSimilarityFinder` uses `LIKE` on SQLite (recording) and MariaDB `_ci` (production). Use `ILIKE` with `\`, `%`, `_` escaped.
+
+### Pitfall 8: Who gets which notification
+- Item-added excludes the actor. Purchased does **not** exclude the actor, and also writes to the reserver when the reserver is not already a subscriber.
+- Revealed is bell-only.
+- Synthetic household peers count as subscribers with both channels enabled [VERIFIED: NotificationService.php:101-197; WishlistSubscriptionService::subscribersOf].
+
+### Pitfall 9: The throttle budget is shared across anonymous routes
+The inline guest key is shared (see Parity Harness Notes). The recording and replay case budget is at most 10 per minute per IP.
+
+### Pitfall 10: The `RegisterEvent` payload would carry the plaintext password
+`fields["password_confirmation"]` is still plaintext when the event fires [api_controller.go:623-671]. Strip it, and strip `password`, from `Payload` (A3).
+
+### Pitfall 11: Purchase is not transactional in PHP
+PHP saves, releases and notifies without a transaction. Go wraps the whole move in one transaction. The bodies are identical, and the stronger atomicity is invisible to clients.
+
+### Pitfall 12: Users with soft deletes
+`User::count()` in `status` and `bootstrap` excludes soft-deleted users. Count `WHERE deleted_at IS NULL`.
+
+## Code Examples
+
+### Digest upsert with first-in-window detection
+```go
+// Port of WishlistDigestQueue::enqueue (models/WishlistDigestQueue.php:29-44):
+// bump or create the row; dispatch the 1800 s job only for a new row.
+var inserted bool
+err := tx.Raw(`INSERT INTO golem15_fonoteka_wishlist_digest_queue (user_id, collection_id, item_count, created_at, updated_at)
+VALUES (?, ?, 1, NOW(), NOW())
+ON CONFLICT (user_id, collection_id) DO UPDATE
+SET item_count = golem15_fonoteka_wishlist_digest_queue.item_count + 1, updated_at = NOW()
+RETURNING (xmax = 0)`, subscriberID, wishlistID).Scan(&inserted).Error
+if err == nil && inserted {
+ _, err = jobs.Dispatch(ctx, tx, WishlistDigestArgs{SubscriberID: subscriberID, WishlistCollectionID: wishlistID},
+ conga.DispatchOpts{Label: "wishlist_digest", Queue: WishlistDigestQueue, Delay: 1800 * time.Second})
+}
+```
+The table, unique constraint and `conga.DispatchOpts` fields are verified [updates/20_remaining.go:172-179; conga/conga.go:105-121]. The `WishlistDigestArgs`/`WishlistDigestQueue` names are recommendations.
+
+### PHP `fputcsv` (`escape = ''`) quoting
+```go
+// Quote when the field contains the delimiter, the enclosure, \n, \r, \t or a space
+// (PHP php_fputcsv); double embedded quotes. Verified against PHP 8.5.10 this session.
+func phpCSVField(s string) string {
+ if strings.ContainsAny(s, ",\"\n\r\t ") {
+ return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+ }
+ return s
+}
+```
+
+### Constant-time public token resolution (port of resolvePublic)
+```go
+// CollectionShareService::resolvePublic (CollectionShareService.php): shape check, LOWER() lookup,
+// public_enabled, kind, then hash_equals.
+if !shareTokenRe.MatchString(token) { return nil, nil } // ^[0-9A-Za-z]{16}$
+var cands []models.Collection
+db.Where("LOWER(public_token) = ? AND public_enabled = ? AND kind = ?", strings.ToLower(token), true, kind).Find(&cands)
+for i := range cands {
+ if cands[i].PublicToken != nil && subtle.ConstantTimeCompare([]byte(*cands[i].PublicToken), []byte(token)) == 1 {
+ return &cands[i], nil
+ }
+}
+```
+GORM's soft-delete scope applies, as Winter's does. The `PublicToken` field type is [ASSUMED]; check it in models/collection.go.
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| PHP inline `Mail::send` on purchase | A River job enqueued in the tx (D-07) | This phase | Mail only after commit |
+| `firstOrNew` + `save` digest coalescing | `ON CONFLICT ... RETURNING` | This phase | Race-free |
+| Cache lock for bootstrap | `pg_advisory_xact_lock` | This phase | Works across processes |
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The Laravel sync queue's `later()` runs the job immediately, so a sync recording deletes the digest row at once | Finding 4 | Low: the recording plan uses a database queue either way |
+| A2 | The `prohibited` failure message is the literal `validation.prohibited` (no catalog key) | Finding 9 | Medium: the wrong 422 text. Record it to settle. |
+| A3 | Stripping `password` and `password_confirmation` from `RegisterEvent.Payload` is acceptable under D-09's "raw register input" | User plugin | Medium: needs user confirmation. A listener might expect them (none does today). |
+| A4 | An empty `summary` serializes as `[]` | CSV | Low: settled by a recording |
+| A5 | A direct `golang.org/x/text/encoding/charmap` import needs user approval (dependency policy) | CSV stack | Low: the hand-port fallback exists |
+| A6 | Eloquent builder `update()` bumps `updated_at` on notification read | Notifications | Low: the column is not visible in responses |
+| A7 | The export `Content-Disposition` format is `attachment; filename=plytarium-kolekcja-YYYY-MM-DD.csv` | CSV export | Low: it is recorded verbatim in the fixture |
+| A8 | The digest queue name `fonoteka.wishlist.digest` and the kind and arg names in the jobs table are acceptable stable names | Jobs | Medium: costly to change later (D-03), so confirm at the checkpoint |
+
+## Open Questions
+
+1. **Approve the framework changes (Findings 1 and 2) for plan 13-01?**
+ - Known: both are blocking in production. `surf` and `conga` are framework modules with READMEs and docs.
+ - Unclear: the user's preference for the conga fix style (route unknown kinds to the insert-only client, or `SkipUnknownJobCheck`).
+ - Recommendation: route unknown kinds through the insert-only client, keep the check for registered kinds, and document it.
+2. **Confirm the job naming contract** (the jobs table: labels per PHP, D-03's queues, the kinds, the digest queue).
+ - Recommendation: confirm at the plan-count checkpoint, since D-03 calls the choice costly.
+3. **D-13's "diff recorded `wishlist_digest_queue` rows":** is the Go-test assertion enough, or build a SQLite row dump?
+ - Recommendation: notifications through API flow steps, digest rows and `summer_jobs` rows in Go tests (Finding 8).
+4. **The `RegisterEvent.Payload` contents (A3).**
+ - Recommendation: a copy of the input without `password` and `password_confirmation`.
+5. **x/text for CSV decoding (A5).**
+ - Recommendation: approve the direct import. It is already in the module graph.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go toolchain | everything | ✓ | go1.27.0 | — |
+| Docker (testcontainers) | DB tests, parity replay | ✓ | 29.7.2 | — |
+| PHP CLI (isolated parity instance) | Recordings (D-12) | ✓ | 8.5.10 | — |
+| SQLite CLI | Optional row dumps (Finding 8) | ✓ | 3.53.4 | tinker |
+| Node | `capture_clients.mjs` (Nuxt/MCP capture) | ✓ | v22.23.2 | Hand-scripted `parity:record --spec` flows |
+| Centrifugo | — | not needed | — | tide fake recorder (127.0.0.1:8424) / memory driver |
+| Discogs / AI providers | — | not needed (`/test` routes pending) | — | — |
+
+**Missing dependencies with no fallback:** none.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Go `testing` (+ testify, Go fuzzing), testcontainers Postgres |
+| Config file | none. `parity/parity_test.go` TestMain starts Postgres. |
+| Quick run command | `cd fonoteka.go && go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short -count=1` |
+| Full suite command | `cd fonoteka.go && go vet ./... && go test ./... -count=1`, plus `cd summercms.go && go vet ./... && go test ./... -count=1` |
+| Parity command | `cd fonoteka.go && go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` |
+| Corpus check | `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` |
+| Docs check (framework) | `cd summercms.go && go test ./cmd/summer -run TestDocsTree -count=1 && summer docs:build --check` |
+| Phase gate | `scripts/check-phase13.sh --self-test && scripts/check-phase13.sh --all` (modeled on `scripts/check-phase12.sh`: `EXPECTED_PORTED=157`, coverage floor 80, `--removal` mutations) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| (framework) | Overlapping constrained routes dispatch in registration order; the route table lists each route | unit | `cd summercms.go && go test ./modules/surf -run 'TestOverlappingConstrainedRoutes' -count=1` | ❌ Wave 0 |
+| (framework) | Dispatch/Enqueue of an unregistered kind succeeds while a worker runs; the worker does not serve its queue | integration | `cd summercms.go && go test ./modules/conga -run 'TestUnregisteredKindWithWorker' -count=1` | ❌ |
+| (framework) | `prohibited` rule semantics and message | unit | `go test ./modules/lagoon -run 'TestValidateRequestProhibited' -count=1` | ❌ |
+| (framework) | tide masks the Content-Disposition date and notification publication `created_at`/id | unit | `go test ./modules/tide -run 'TestNormalizeContentDispositionDate|TestNormalizeNotificationPublication' -count=1` | ❌ |
+| API-04 | 4 notification routes replay; foreign id → 404 page | parity + integration | `go test ./parity -run 'TestParityCorpus/.*notifications'` ; `go test ./plugins/golem15/fonoteka -run TestNotificationsRoutes -race` | ❌ |
+| API-06 | Credentials CRUD, encryption at rest, no secret in any body, org 404/403, Discogs shared mirror, 500 page on validation | parity + integration | `go test ./parity -run 'TestParityCorpus/.*credential'` ; `go test ./plugins/golem15/fonoteka -run 'TestCredentialsCRUD|TestCredentialSecretsNeverSerialized' -race` | ❌ |
+| API-07 | onboarding status/bootstrap (empty DB, 409 replay, concurrent bootstrap → one owner) | parity flow + integration | `go test ./parity -run 'TestFonotekaNuxtFlows/onboarding'` ; `go test ./plugins/golem15/fonoteka -run TestBootstrapConcurrent -race` | ❌ |
+| API-07 | Register with `invitation_token` → PendingInvitationRegistration; without → collection provisioned; user plugin `/register` body unchanged | integration | `go test ./plugins/golem15/fonoteka -run TestRegisterInvitationListener -race` ; `go test ./plugins/golem15/user/... -run 'TestRegister' -count=1` | ❌ / ✅ (`user/register_test.go`) |
+| API-07 | `invitations/{token}` inspection (unavailable / pending) | parity | `go test ./parity -run 'TestParityCorpus/.*invitations_{token}_public'` | ❌ |
+| API-07 | Public views, facets, headers `no-store, private`, D-14 buckets per route, pubfail 429 after 10 | parity flow + unit | `go test ./parity -run 'TestFonotekaNuxtFlows/public-anonymous'` ; `go test ./plugins/golem15/fonoteka -run 'TestPublicBucketsPerRoute|TestPubfailCounter' -race` | ❌ |
+| API-03 | Wishlist albums CRUD on both groups; `prohibited` 422; similar; share; settings; household | parity | `go test ./parity -run 'TestParityCorpus/.*wishlist'` | ❌ |
+| API-03 | Subscriptions by token and collection, forced household state, subscribers list | parity + integration | `go test ./plugins/golem15/fonoteka -run TestWishlistSubscriptions -race` | ❌ |
+| API-03 | Reserve 201 / 422 own / 409 disabled / 409 race (one winner); cancel 404 for a foreign reserver; reveal one-way, bell only | integration | `go test ./plugins/golem15/fonoteka -run 'TestReserveConcurrent|TestRevealIdempotent|TestReservationMask' -race` | ❌ |
+| API-03 | Purchase: move, release, notifications (subscribers + reserver), mail jobs after commit only, none on rollback; template and locale | integration (postcard memory) | `go test ./plugins/golem15/fonoteka -run 'TestPurchaseSideEffects|TestPurchaseMailAfterCommit' -race` | ❌ |
+| API-03 | Item-added: bell per ws-enabled subscriber except the actor; digest upsert bumps `item_count`; one digest Dispatch per window with label `wishlist_digest` and delay 1800 s, on JWT, token-group and bulk create paths | integration | `go test ./plugins/golem15/fonoteka -run 'TestWishlistItemAddedOncePerPath|TestDigestCoalescing' -race` | ❌ |
+| API-03 | nuxt-wishlist and mcp-wishlist flows; notification publication goldens | parity flow | `go test ./parity -run 'TestFonotekaNuxtFlows/(nuxt-wishlist|mcp-wishlist)|TestBroadcastGoldens'` | ❌ |
+| API-05 | CSV store/show/mapping/row/commit/cancel; job rows (label, count, metadata, status); cancel → stopped + is_canceled; commit idempotent 202 | parity flow + integration | `go test ./parity -run 'TestFonotekaNuxtFlows/nuxt-csv'` ; `go test ./plugins/golem15/fonoteka -run 'TestCsvCommitCAS|TestCsvJobRows|TestCsvCancel' -race` | ❌ |
+| API-05 | Parser and detector truth tables vs PHP (encodings, delimiters, combined, canonical, MAX_ROWS, formula guard) | unit | `go test ./plugins/golem15/fonoteka/classes/csv -count=1` | ❌ |
+| API-05 | Export on both groups; BOM; `fputcsv` quoting; formula-safe cells; filename | parity + unit | `go test ./parity -run 'TestParityCorpus/.*export_csv'` ; `go test ./plugins/golem15/fonoteka/classes/csv -run TestPHPFputcsv` | ❌ |
+| API-05 | D-05 seam: a valid pick → `discogs_unavailable`; never resolves without a draft | integration | `go test ./plugins/golem15/fonoteka -run TestCsvRowPickSeam -race` | ❌ |
+| C-01 | Route table: the 58 routes exactly once on the right groups, one `inv.scope` per token route, PHP `Where` constraints, the 4 Phase 14 routes absent | unit | `go test ./plugins/golem15/fonoteka -run TestRouteTablePhase13 -count=1` | ❌ (extend the `routes_table_phase12_test.go` pattern) |
+| C-04 | Request-DTO fuzz over every Phase 13 write endpoint | fuzz | `go test ./plugins/golem15/fonoteka -run '^FuzzWriteEndpoints$'` + `-fuzz '^FuzzWriteEndpoints$' -fuzztime 60s` | ✅ extend `write_endpoints_fuzz_test.go` |
+| C-07 | Ported count 157; the pending 4 never pass | parity | `go test ./parity -run TestParityCorpus/coverage` | ✅ update `expectedPortedRoutes` |
+
+### Sampling Rate
+- **Per task commit:** the quick run command plus `go vet` in the touched repo.
+- **Per wave merge:** the full suite in both repos plus the parity command and the corpus check.
+- **Phase gate:** `scripts/check-phase13.sh --all` green before `/gsd-verify-work`.
+
+### Wave 0 Gaps
+- [ ] `surf` constraint-aware overlap dispatch, plus a test (blocks every wishlist route).
+- [ ] `conga` unregistered-kind insert, plus a test.
+- [ ] `lagoon` `prohibited` rule; tide Content-Disposition date and notification publication masks.
+- [ ] `php_parity.sh` `QUEUE_CONNECTION` override; `capture-rules.yaml` `share:wishlist` capture.
+- [ ] `fonoteka_reset.php` + `seedFonotekaCase` states: `wishlist`, `csv`, `credentials`, `empty`, `invite-for-register`.
+- [ ] `scripts/check-phase13.sh` (copy of the check-phase12 structure).
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | Bootstrap creates the first owner once (advisory lock, in-tx count, 409). Register uses the shared user-plugin path (hashing, JWT). |
+| V3 Session Management | no | Stateless bearer, unchanged |
+| V4 Access Control | yes | Own wishlist only for write, share and settings. Peer views and reservations only via `reservableWishlistIds`. Subscribe-by-collection only for `wishlistsVisibleTo`. CSV imports scoped by `user_id` AND `AccessibleBy(collection)`. Org credentials writable by owner/admin/site-admin only. Token-group isolation. 404-never-403 on ids. |
+| V5 Input Validation | yes | `lagoon.ValidateRequest` (+ `prohibited`), fill allow-lists (`CredentialFillFields`), the request-DTO fuzz, CSV size, row and encoding limits |
+| V6 Cryptography | yes | `lagoon.Encrypted` (AES-256-GCM) for credentials, `crypto/rand` share tokens, sha256 invitation hashes, `subtle.ConstantTimeCompare` |
+| V7 Error/Logging | yes | Winter production pages, no secrets or tokens in logs or job args (purchase-mail args carry names only), no register payload secrets (A3) |
+| V8 Data Protection | yes | Private CSV bucket; credentials never serialized (`json:"-"`, responses secret-free) |
+| V11 Business Logic | yes | Reserve race (row lock + unique), commit CAS, digest coalescing, throttles (share regenerate and CSV store 10/min), anti-enumeration |
+| V12 Files | yes | CSV upload: extension and 5 MiB checks, private storage, formula guard on export |
+| V13 API | yes | One `inv.scope` per token route; reserve, purchase, share, settings and peer routes absent from the token group |
+
+### Known Threat Patterns (candidate T-13-xx, each with a failing-when-broken test)
+
+| ID | Pattern | STRIDE | Standard Mitigation |
+|----|---------|--------|---------------------|
+| T-13-01 | Public token guessing / enumeration | Information disclosure | 16-character shape check, constant-time compare, pubfail 10/60 s per IP (peek before, hit on failure), the inline 10/min on resolve, per-token 60 and per-IP 120 on albums |
+| T-13-02 | Public view leaking non-public data (ratings, prices, notes, shelf, reservations) | Information disclosure | `serializePublicAlbum` field set; `rating` param → 422; facets drop zero-count rows; no reservations on public-wishlist |
+| T-13-03 | Disabled or regenerated share still resolving | Spoofing | `public_enabled` + exact token; regenerate rotates under `FOR UPDATE` |
+| T-13-04 | Reserving on one's own wishlist or revealing someone else's reservation | Elevation of privilege | 422 own; reveal only via the caller's own wishlist; cancel only by the reserver (404 otherwise) |
+| T-13-05 | The wishlist owner learning the gift giver before reveal | Information disclosure | `stateForViewer` mask (`reserved_by` omitted for owner and not revealed) |
+| T-13-06 | IDOR on peer wishlists, subscriptions and wishlist albums | Information disclosure / Tampering | `reservableWishlistIds` / `wishlistsVisibleTo` / own-wishlist scoping; Winter 404 pages identical for foreign and missing |
+| T-13-07 | Personal token reaching reserve, purchase, share, settings or peer routes | Elevation of privilege | Absent from the token group (route-table test) |
+| T-13-08 | Credential secret disclosure (responses, logs, JSON marshal) | Information disclosure | `lagoon.Encrypted` redacting marshal, `json:"-"`, show returns `provider`, `model`, `base_url` only |
+| T-13-09 | Org credential set by a plain member; Discogs `shared` without rights | Elevation of privilege | `CanManageOrg` → 403 `Forbidden` / `shared_forbidden` |
+| T-13-10 | Credential owner FK re-pointed by mass assignment | Tampering | `CredentialFillFields` + fuzz |
+| T-13-11 | `base_url` used as SSRF | Tampering | Stored only in Phase 13 (`nullable|url`); outbound use and SSRF guard are Phase 14 |
+| T-13-12 | CSV import of another user, or into a collection one lost access to | Elevation of privilege | `findVisible`: `user_id` = caller AND `AccessibleBy(collection_id)` |
+| T-13-13 | CSV upload served publicly or path traversal | Information disclosure | Private bucket, server-generated `fonoteka-csv//.csv` key |
+| T-13-14 | CSV formula injection in exported files | Tampering | `formulaSafe` port (`FORMULA_PATTERN`) |
+| T-13-15 | CSV parse DoS (huge or binary file) | DoS | 5 MiB cap, `MAX_ROWS = 5000`, NUL rejection, `throttle:10,1` on store |
+| T-13-16 | Row edit attaching an arbitrary Discogs release | Tampering | Candidate allow-list; D-05 seam never fabricates (`discogs_unavailable`) |
+| T-13-17 | Double commit or double writer | Tampering | CAS `preview` → `importing`; idempotent 202 replay |
+| T-13-18 | Concurrent bootstrap minting two owners | Elevation of privilege | Advisory lock + in-tx count + 409; users.email unique backstop |
+| T-13-19 | Register hook accepting a stale, foreign or expired invitation | Spoofing | Hash match + not accepted/revoked + `expires_at > now` + LOWER(trim(email)) equality |
+| T-13-20 | Plaintext password exposed to event listeners | Information disclosure | Payload strips `password` and `password_confirmation` (A3) |
+| T-13-21 | Notification read or marked for another user | Information disclosure / Tampering | `user_id`-scoped update; 0 rows → 404 page |
+| T-13-22 | Unregistered job kinds lost or 500 in production | DoS / Repudiation | Finding 2 fix + unserved queues + test |
+| T-13-23 | Overlapping route dispatch sending a request to the wrong handler | Elevation of privilege | Constraint-aware dispatch tests for all 4 pairs |
+
+## Recommended Plan Split (for the plan-count checkpoint)
+
+**Six plans, sequential.** Waves 1 → 6: every feature plan edits `routes.go`, `parity/manifest.yaml` and the seeds. Each plan starts with a tracer: one route recorded, ported, flipped to `ported` and passing before the rest.
+
+1. **13-01 Framework gaps and planning docs (summercms.go; fonoteka.go parity scaffolding).**
+ - `surf` overlap dispatch, the `conga` unregistered-kind insert, `lagoon` `prohibited`, the tide Content-Disposition date and notification publication masks, each with README and docs updates.
+ - `php_parity.sh` `QUEUE_CONNECTION` override and the `capture-rules` `share:wishlist` capture.
+ - ROADMAP and REQUIREMENTS rewording per D-01, D-02 and D-06, plus Phase 14 gaining wishlist match/apply-release and the credential `/test` routes.
+ - The D-15 manifest fix.
+ - Tracer: a surf test dispatching the four overlap pairs.
+2. **13-02 Notifications, credentials, onboarding, invitation inspection and the register hook (14 routes; fonoteka.go + sm-user-plugin).**
+ - Tracer: `GET notifications`.
+ - The additive user-plugin exports (D-09, D-11) committed in the submodule, then the pointer bump.
+ - The fonoteka listener (D-10), bootstrap with the advisory lock, inspection, the credentials CRUD with Winter 500 pages.
+ - Recordings: route cases plus the `onboarding` flow.
+3. **13-03 Wishlist (30 routes).**
+ - Tracer: `GET wishlist/albums` on both groups.
+ - The resolver and provisioner, visibility scopes, the subscription and reservation services, the `AlbumDTO.reservation` key, the similarity finder.
+ - Share, settings and household; purchase with the purchase-mail job (worker and templates); the item-added callback branch with digest upsert and Dispatch.
+ - Recordings: `nuxt-wishlist` (database queue), `mcp-wishlist`, the notification publication goldens.
+4. **13-04 CSV import and export (8 routes).**
+ - Tracer: `GET import/csv/{id}` 404 then `POST import/csv`.
+ - The parser package port with PHP truth tables, the private bucket, the 6 import routes with CAS, `Dispatch`/`CancelJob` and the D-05 seam.
+ - The export with the `fputcsv` port on both groups.
+ - Recordings: `nuxt-csv` (database queue) plus route cases.
+5. **13-05 Public views and D-14 (6 routes).**
+ - Tracer: `GET public/{token}`.
+ - `ResolvePublic`, the public search mode (`TEXT_FIELDS_PUBLIC`, collection-only scope), facets, `serializePublicAlbum`.
+ - The `no-store, private` header fix, the per-route buckets, the pubfail counter.
+ - Recordings: the `public-anonymous` flow.
+6. **13-06 Unit tests, security and the gate.**
+ - `TestRouteTablePhase13`, the fuzz extended to every Phase 13 write endpoint, T-13-01..23 tests, the CSV truth tables, coverage ≥ 80% per package.
+ - `scripts/check-phase13.sh` with `--removal` mutations, `13-VALIDATION.md` sign-off, and the security-review agent (public tokens, credentials, authorization).
+
+**Alternative (5 plans):** merge 13-05 into 13-03, since public-wishlist depends on the wishlist anyway. This is not recommended: 13-03 is already the largest plan at 30 routes, and the public surface deserves its own security-focused review slice, as the user wanted in Phase 12 ("ported and security-reviewed in one place").
+
+**Ported-count progression:** 99 → 113 (13-02) → 143 (13-03) → 151 (13-04) → 157 (13-05).
+
+## Sources
+
+### Primary (HIGH confidence, read this session)
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` (all 567 lines), `Plugin.php:60-205`
+- PHP controllers: NotificationApiController, Wishlist{Album,AlbumReservation,Household,PeerAlbum,Settings,Share,Subscription}Controller, CsvImportApiController, CsvExportApiController, AiCredentialController, OrgAiCredentialController, DiscogsCredentialController, OnboardingController, InvitationApiController, PublicShareApiController; middleware PublicShareHeaders; traits SerializesFonoteka, SerializesPublicAlbum
+- PHP services and models: NotificationService, WishlistSubscriptionService, AlbumReservationService, ActiveWishlistResolver, WishlistProvisioner, CollectionShareService, AlbumSimilarityFinder, AlbumWriteService::moveToCollection, AiGate, AiConfigResolver, DiscogsConfigResolver, DiscogsGate, OrgAccess, models/WishlistDigestQueue, CsvImport, CsvImportRow, Collection scopes, UserAiCredential; jobs/AlbumCsvImportJob, AlbumCsvMatchJob, WishlistDigestJob; apparatus JobManager and JobStatus; websockets CentrifugoClient and BroadcastableModel; user ApiController@register and AuthManager; lang/pl csv_import; views/mail wishlist_item_purchased*
+- Laravel 9.x vendor: Validator implicit rules, `validateProhibited`
+- River v0.47.0: client.go:2270-2276, internal/jobexecutor/job_executor.go:219
+- Go app: routes.go, plugin.go, jobs.go, mail.go, classes/{notification_service,invitation_service,gates,access,credential_write_service,org_provisioner,share_service,album_search,serialize_album}.go, models/*, middleware/public_share_headers.go, controllers/api/http_errors.go, updates/*.go (constraints)
+- sm-user-plugin: classes/events.go, controllers/api_controller.go (Register, apiArray, mintFor), README
+- Framework: surf/router.go, surf/limiter.go, limiter_store.go, bodylimit.go; conga/conga.go, client.go, worker.go, record.go; lagoon/validate_rules.go, encrypted.go; tide/diff.go, centrifugo_golden.go
+- Parity: manifest.yaml (scripted comparison), fixtures/routes/* in scope, parity/README.md, php_parity.sh, capture-rules.yaml, parity_test.go, fonoteka_seed_test.go, fonoteka_flows_test.go, db_capture.go
+- Probes run this session: the ServeMux conflict probe (Go 1.27), the PHP 8.5.10 `fputcsv` vs Go `encoding/csv` comparison, tool versions
+
+### Secondary (MEDIUM confidence)
+- Phase 12 CONTEXT, RESEARCH, VALIDATION and SUMMARYs (patterns, verify commands, gate script structure); 11-CONTEXT D-10
+
+### Tertiary (LOW confidence)
+- The assumptions A1, A2, A4, A6 and A7 above (unprobed framework behaviours)
+
+## Metadata
+
+**Confidence breakdown:**
+- Route inventory and the PHP contract: HIGH. Every route, controller and constraint was read; the manifest and fixtures were compared by script.
+- Framework gaps (Findings 1, 2, 6, 9): HIGH. Proved by a probe or by reading the source.
+- Recording strategy (Findings 4, 5, 8): HIGH on the problems, MEDIUM on the exact recipes.
+- Plan split and sizing: MEDIUM.
+
+**Research date:** 2026-10-02
+**Valid until:** 2026-11-01 (stable codebase; re-check if Phase 12.2 changes `surf` or `conga`)