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`)