# 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 (RESOLVED) All five were resolved at the plan-count checkpoint on 2026-10-02. 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. - RESOLVED: the user approved the surf overlap dispatch and the conga unregistered-kind insert onto queues nothing serves until Phase 14 (plan 13-01). 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. - RESOLVED: labels `fonoteka.csv.import`, `fonoteka.csv.match` and `wishlist_digest` kept verbatim; the contract is pinned in `classes/job_contract.go` (plan 13-01), rated costly and signed off. 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). - RESOLVED: real row goldens via a `php_parity.sh rows` dump (digest-queue rows in 13-03, job rows in 13-04), plus Go tests. 4. **The `RegisterEvent.Payload` contents (A3).** - Recommendation: a copy of the input without `password` and `password_confirmation`. - RESOLVED: Payload strips `password` and `password_confirmation` (user decision). 5. **x/text for CSV decoding (A5).** - Recommendation: approve the direct import. It is already in the module graph. - RESOLVED: the direct `golang.org/x/text` import is approved. ## 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`)