Six sequential plans: framework gaps, notifications/credentials/onboarding, wishlist, CSV, public views, unit tests and gate. Research open questions marked resolved per the plan-count checkpoint.
107 KiB
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:
- Wishlist (30 routes): about 1,480 PHP controller lines plus five services. They reuse the Phase 12 album write path almost entirely.
- CSV import (6 routes) and export (2 routes): a 578-line controller over roughly 1,000 lines of parser classes.
- 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:
- The router cannot register the wishlist routes as they are. The framework's
surfrouter maps PHP routes straight onto Gonet/httpServeMux patterns. Go treats four pairs of wishlist routes as conflicting and panics, so the app fails at boot withsurf: route conflict. Examples areGET wishlist/{collectionId}/albumsagainstGET wishlist/albums/{id}, andDELETE wishlist/{collectionId}/subscribeagainstDELETE wishlist/albums/{id}. PHP's numeric->where()constraints make each pair disjoint, but ServeMux ignores those constraints. A framework fix insurfis needed (Finding 1). - 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 modecongainserts 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). fonoteka.csv.importandfonoteka.csv.matchare PHPJobManagerlabels, not queue names. They become thesummer_jobs.labelcolumn. 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).- 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. Thenuxt-csvandnuxt-wishlistrecordings therefore need a non-sync queue to show the "queued, not run" state that D-04 and D-13 describe (Finding 4). - 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 useseed_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 thefonotekareset, as Phase 12 did (Finding 5). - Three Go-side byte mismatches (Finding 6):
- Go's public-share headers send
Cache-Control: private, no-store, but PHP sendsno-store, private, and tide compares the header byte for byte. - The CSV export's recorded
Content-Dispositionheader carries the recording date, so a later replay fails. - PHP's
fputcsvquotes fields that contain a space or a tab. Go'sencoding/csvdoes not.
- Go's public-share headers send
- 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). - 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_queuerows in Go tests (Finding 8).
Primary recommendation: six plans, run sequentially:
- Framework gaps and planning docs (
summercms.go). - Notifications, credentials, onboarding, invitation inspection and the user-plugin hook.
- Wishlist.
- CSV import and export.
- Public views with D-14.
- 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>
User Constraints (from CONTEXT.md)
Locked Decisions
Phase 13/14 boundary
- D-01:
POST wishlist/albums/{id}/matchandapply-release(WishlistReleaseMatchController) move to Phase 14 with the Discogs client (INTG-01), next to the album match/apply-release routes. Their manifest entries staypending(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 theshared=true|falsemirror into the org credential, owner/admin only, else 403), encrypted storage,FONOTEKA_AI_ORG_LOCKorg-lock and the PHP resolution order (AI: admin → backend Settings global model, then org-lock tier, user, org, else error; Discogs: admin → envDISCOGS_TOKEN, then user, org). The two live-call routesai-credential/testanddiscogs-credential/teststaypendingfor 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/csv202 with throttle:10,1,GET import/csv/{id},PATCH .../mapping,PATCH .../rows/{rowId},POST .../commit,POST .../cancel,GET export/csvon both groups).commit(after the preview→importing compare-and-swap, idempotent 202 on replay) and non-canonicalmappingenqueue real River jobs whose kinds, queues (fonoteka.csv.import,fonoteka.csv.match) and args mirror PHP'sAlbumCsvImportJob/AlbumCsvMatchJob. The job bodies are JOBS-02 in Phase 14.cancelmarks the job rows canceled as PHP does;showreads 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
showreports 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 callsDiscogsClient::getReleaseinline) 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-notificationsconsole command, already in Phase 14. Success criterion 2 and API-04 drop "prune" at plan time. The 4 routes (notificationscapped at 50 newest-first,unread-count,read-all,{id}/read) are ported.
Wishlist mail and digest
- D-07:
wishlist/albums/{id}/purchasekeeps PHP's DB effects (move viacollection_idupdate,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_queuerow exactly asWishlistDigestQueue::enqueuedoes (first enqueue in a window creates the row and enqueues the 1800 s delayed digest job; later ones bumpitem_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'sRegisterEventgains a genericPayload map[string]anycarrying the raw register input, mirroring Winter'sgolem15.user.registerpayload. 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 readPayload, removing or reshaping it breaks them across every project using the user plugin. - D-10: fonoteka registers a listener that ports
Plugin.php:180: hashPayload["invitation_token"]; if it matches a valid invitation for the user's email, upsertPendingInvitationRegistration(consumed later by the existingguardPendingInvitation); otherwise provision a collection. - D-11:
onboarding/bootstrapregisters 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
tidecapture 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}andpublic-wishlist/{token}resolve, albums, album detail, a bad token, and thepubfail:<ip>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 withinvitation_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
notificationsandwishlist_digest_queuerows and the Centrifugo publications (P11 D-10 tooling,timestamp/actornormalised). Mail recipients, locale and template are asserted in Go tests through postcard'smemorydriver.
Fixes without a decision (parity rule)
- D-14: The public group's
resolveroutes (public/{token},public-wishlist/{token}) get PHP's inlinethrottle:10,1only.fonoteka-public-token+fonoteka-public-ipapply only to the albums index/show routes. Currentlyroutes.goapplies 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 oneinv.scope:read|writeper 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 throwsHttpException(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,[]notnull, Carbon+00:00, pagination envelope withoutlinks(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 withkind='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
albumAddedCallbackGORM 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_idrow-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/testroutes, CSV job workers, theselected_discogs_idrow-edit branch, the digest worker and mail, andfonoteka: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. </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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 vetandgo 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 isfonoteka.go, and the user plugin is thesm-user-pluginsubmodule atfonoteka.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 intofonoteka.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'sREADME.mdand the affecteddocs/pages in the same change. This applies here tosurf,conga,lagoonandtide.- Checkers:
go test ./cmd/summer -run TestDocsTreeandsummer docs:build --check. - Every identifier named in a doc must exist.
- Checkers:
- 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]:
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]:
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]:
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)
congainserts kinds that have no registered job through the insert-only client, which never checks kinds. The alternative is settingSkipUnknownJobCheck: trueon 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.matchand a digest queue such asfonoteka.wishlist.digest. Neverdefaultormail, and do not add them toconfig/queue.yamluntil 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:
commitrunsAlbumCsvImportJobinside the request, so the import isdonebefore the 202 returns.WishlistDigestQueue::enqueuerunsWishlistDigestJobat once, because the sync driver'slater()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.shhonoursQUEUE_CONNECTION="${QUEUE_CONNECTION:-sync}".nuxt-csvandnuxt-wishlistare recorded withQUEUE_CONNECTION=database. Winter ships the jobs migrations2014_10_01_000010_Db_Jobs.phpand successors [VERIFIED: ls modules/system/database/migrations].- The purchase step's album
updatedbroadcast, which is queued, is recorded separately withparity:broadcastsunder sync, in a state with no email-enabled subscriber. Thenotification:*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}/subscribeand the token-groupPOST 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 publicalbums/{id}routes, and the token-groupPUT 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
- Public headers. PHP sends
Cache-Control: "no-store, private"[VERIFIED: fixtures/routes/GET___fonoteka_api_v1_public_{token}_public_share.yaml:14]. Go setsdst.Set("Cache-Control", "private, no-store")[VERIFIED: middleware/public_share_headers.go:32]. tide comparesCache-Controlas exact strings [VERIFIED: tide/diff.go:43-90]. Fix the Go string tono-store, private. C-06 already writes it that way. - Export filename date. The export fixture records
Content-Disposition: "attachment; filename=plytarium-kolekcja-2026-09-17.csv", and tide comparesContent-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 inplytarium-kolekcja-YYYY-MM-DD.csv, or give the Go export an injectable clock and freeze it in the replay. - CSV quoting. PHP's
fputcsv($o, ..., ',', '"', '')quotes fields that contain a space or a tab. Go'sencoding/csvdoes 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 as1[VERIFIED: same probe,1959,0,,1]. Portfputcsvas a small writer and do not useencoding/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, <title>Błąd strony</title>); parity/README.md "an invitation ValidationException ... goes through Winter's error handler as a 500"].
The same path covers:
AiCredentialController::storeandOrgAiCredentialController::store:$request->validateandValidationException::withMessages.DiscogsCredentialController::store: the same two calls, plus the regex message.OnboardingController::bootstrap:$request->validate, includingunique: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-wishlistflow addsGET notificationssteps as each recipient after item-add, purchase and reveal. Their bodies are the rows{id,type,payload,read_at,created_at}. wishlist_digest_queuerows 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:newpublications carryidandcreated_atunder$.data.payload[VERIFIED: NotificationService.php:273-278]. The tide normalizer masks onlytimestamp,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
lagoonlacksprohibited. The rule arity table has noprohibitedand nounique[VERIFIED: lagoon/validate_rules.go:35-43]. The wishlist album rules need'condition' => 'prohibited'and'shelf' => 'prohibited'[VERIFIED: WishlistAlbumApiController.php:296, 304]. Laravel definesvalidateProhibitedas! $this->validateRequired(...)and it is not implicit [VERIFIED: vendor/laravel/framework (9.x-dev) ValidatesAttributes.php:1821-1824, Validator.php:204-224]. Neither Winter'splnor itsenvalidation catalog has aprohibitedkey [VERIFIED: grep]. The message is therefore probably the literal keyvalidation.prohibited[ASSUMED]. Record it.unique:usersfor 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
SearchAlbumsis 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 needsTEXT_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 inclasses/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 oneinv.scopeper 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.
indexmaps{id, type, payload, read_at, created_at}withtoIso8601String.markReadscopes the update byuser_idand turns zero rows intoHttpException(404)[VERIFIED: NotificationApiController.php:17-67]. Eloquent-builderupdate()also bumpsupdated_at[ASSUMED]. - Go has:
models.NotificationwithPayload lagoon.Jsonable[map[string]any][VERIFIED: models/notification.go].classes.WriteNotification, which publishesnotification:newthennotification:counton 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.RawMessageso 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]:
showreturns{configured:false}or{configured,provider,model,base_url}.storevalidatesprovider: required|in:claude,openai,api_key: nullable|string,model: nullable|string|max:255,base_url: nullable|url. A missingapi_keyreuses the stored key, otherwise it throwsValidationException::withMessages(['api_key' => ['The api key field is required.']]).updateOrCreatewrites 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), thencanManage→ 403{"error":"Forbidden"}. - Discogs:
- The token is trimmed. The regex is
/^[A-Za-z0-9_\-]{10,255}$/, with a custom messagegolem15.fonoteka::lang.discogs.token_invalid_format. shared && !canManage→ 403 withdiscogs.shared_forbidden.- The user and org writes happen in one transaction.
status()={configured: DiscogsGate::allows, source: resolver source, shared: org credential exists}.
- The token is trimmed. The regex is
- Go has:
- The four credential models with
lagoon.Encryptedandjson:"-". CredentialFillFields = []string{"provider", "model", "base_url"}[VERIFIED: classes/credential_write_service.go:16] and its fuzz test.IsSiteAdmin,CanManageOrg,AIAllowed,ResolveDiscogsConfigandDiscogsAllowed[VERIFIED: classes/gates.go].ProvisionOrgFor[VERIFIED: classes/org_provisioner.go:28].- The config keys
golem15.fonoteka.ai_org_lockandgolem15.fonoteka.discogs.token[VERIFIED: gates.go consts; config/config.yaml].
- The four credential models with
- Missing:
- The seven handlers.
- The Discogs lang strings for
shared_forbiddenandtoken_invalid_format. - The
AiConfigResolverport (PHP AiConfigResolver.php, 72 lines). Its only callers are/testand recognize (Phase 14), so it is optional here. D-02's resolution order is fully observable in Phase 13 throughAIAllowed/me/contextand Discogsstatus(). AIAlloweddoes not have PHPAiGate'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, ...)wrapsDB::transaction.- Inside the transaction: a recount,
Organisation::create(['name' => ..., 'slug' => Str::slug(org_name)]),Auth::register($reg, true, false),organisation_idplusorganisation_role = 'owner',Event::fire('golem15.user.register', [$user, $reg]),JWTAuth::attempt, then{token, user: getApiArray()}. - A lock timeout → 409.
- A count before the lock → 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]:
// 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, thenfields["password"] = hashed, thenlagoon.Fill, thenCreate; _ = 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
mintForandapiArray[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 withpasswordandpassword_confirmationremoved. By the time the event fires,fields["password"]holds the bcrypt hash andpassword_confirmationstill 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, fireRegisterEvent./registerand the bootstrap share it. - Exported
IssueToken(mintFor) andAPIArray(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_idonpending_invitation_registrations(CONSTRAINT fonoteka_pending_invite_user_unique UNIQUE (user_id)) [VERIFIED: updates/20_remaining.go:58].ON CONFLICT (user_id) DO UPDATEis therefore the right upsert. models.Slug[VERIFIED: models/slug.go:21]. Check it againstStr::slugfor 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:subscribeisupdateOrCreateand setssubscribed_at = now().subscribersOfadds synthetic household peers withws_enabledandemail_enabledtrue.followerCountOfcounts the union of peers and subscribers, excluding the owner.stateForcarriesforcedfor household peers.subscriptionsForlists household wishlists sorted by owner name, then other subscriptions.
AlbumReservationService:reservetakesFOR UPDATEon the album, checks for an existing reservation (→HttpException(409)), then creates.revealis idempotent, writesrevealed_atand thereservation_revealedbell notification only, no mail.releaseForAlbumdeletes and returns the reserver id.stateForViewerhidesreserved_byfrom the owner before reveal.
CollectionShareService::resolvePublic: the 16-character regex,whereRaw('LOWER(public_token) = ?'),public_enabled,kind, thenhash_equals.stateForbuilds the/w/or/k/path.AlbumSimilarityFinder: name and artistLIKEwithaddcslashes('\\%_'), an optional year, thephotosembed,orderBy('name'), limit 6.AlbumWriteService::moveToCollection: setscollection_idand saves, thenreleaseForAlbum, thennotifyWishlistItemPurchased($album, $from, $reserverUserId)[AlbumWriteService.php:270-282].
Scopes:
Collection::wishlistsVisibleTo($user)=kind='wishlist' AND owner_id INthe owners and editors of every collection the user is a member of, the user included [VERIFIED: models/Collection.php:247-264].accessibleByalready exempts the owner's wishlist from a token pin. GoAccessibleBymirrors this [VERIFIED: classes/access.go:36-48].
Go has:
- The models
WishlistSubscription,WishlistDigestQueue,AlbumReservationandCollection. The unique constraints areUNIQUE (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,RenameShareand 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.AlbumsAccessibleByandNotificationActor.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
wishlistsVisibleToscope and the reservable ids. - The subscription service.
- The reservation service and the reservation DTO.
AlbumDTOhas noreservationkey; addReservation *ReservationDTO \json:"reservation,omitempty"``, whose conditional keys are omitted, not nulled. - The similarity finder. Use
ILIKEwith escaping: PostgresLIKEis case-sensitive, while the recording ran on SQLite and production runs on MariaDB_ci. prohibitedrules.- 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 varsalbumNameandwishlistName, subject"Pozycja na liście życzeń została kupiona"/"A wishlist item has been purchased"and layoutplytarium[VERIFIED: views/mail/wishlist_item_purchased*.htm]. Gomail.goregisters 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 == wishlistbranch then calls a newNotifyWishlistItemAdded. - Wire a job queue through an atomic pointer, the way
realtimeServiceis wired [notification_service.go:204-209]. - GORM's default transaction is on (no
SkipDefaultTransactionanywhere inmodules/[VERIFIED: grep]), soconga.Dispatchsees a*sql.Txand joins it [VERIFIED: conga.go:494-500]. - Publications go out after commit through
lighthouse.Emiton 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_unreadable422. Size overMAX_BYTES = 5242880→csv_too_large. CsvAlbumParser::parse. ACsvParseException→ error with$e->errorCodeand 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/<uid>/<uuid>.csvon the private disk, create the import with statuspreview(canonical) ormapping,replaceRows, then 202.
- A missing or invalid file, or an extension other than csv/txt →
show: rows paginated bypageandper_page,max(1, min(50)).updateMapping:- Requires
isBeforeCommit(mapping, preview or failed), elsecsv_already_committed. - Reads
column_map(or the whole body), re-parses, cancels the old match job and replaces the rows. - Sets status
preview(canonical) oruploaded. When not canonical, it dispatches the match job and setsmatch_job_id.
- Requires
updateRow:skip/accept_csvchange the row status only.- The
selected_discogs_idpath validates a digit string that must be one of the row'scandidates_json[*].discogs_id, thenDiscogsGate::allows(elsediscogs_unavailable), then the inlinegetRelease.
commit: compare-and-swappreview→importing. A replay when the status is done or importing → 202 with the current job. Otherwisecsv_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 statuscanceled.serializeImport:summarycomes frompluck('aggregate','status'), and an empty result encodes as[](a PHP empty array) [ASSUMED].progresscomes 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", theHEADERSrow, then rows streamed withlazy(100). Content-Type: text/csv; charset=UTF-8.- The filename is
plytarium-kolekcja-<Y-m-d>.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 422validation_failedat 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_failedanddiscogs_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.<snake> 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.phpwithPARITY_CASE=<extras>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 <spec> --output <fixture> --vars ... --rules parity/capture-rules.yamlrecords 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. UseisolatedFlowDB[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,1key for guests isdomain|ipand 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=filepersists limiter state, so runphp_parity.sh reset(which runscache:clear) before each anonymous recording.
The pubfail sequence: record 10 × GET public/<bad-but-well-formed-or-malformed>/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}(plusg.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:<ip> (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/<uid>/<uuid>.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.Writerfor the export: wrong quoting (Finding 6). - Putting a CSV or digest job on
defaultormailbefore 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,modelandbase_urlonly. The models are alreadyjson:"-". - 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 |
| 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
// 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
// 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)
// 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.
- Approve the framework changes (Findings 1 and 2) for plan 13-01?
- Known: both are blocking in production.
surfandcongaare 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).
- Known: both are blocking in production.
- 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.matchandwishlist_digestkept verbatim; the contract is pinned inclasses/job_contract.go(plan 13-01), rated costly and signed off.
- D-13's "diff recorded
wishlist_digest_queuerows": is the Go-test assertion enough, or build a SQLite row dump?- Recommendation: notifications through API flow steps, digest rows and
summer_jobsrows in Go tests (Finding 8). - RESOLVED: real row goldens via a
php_parity.sh rowsdump (digest-queue rows in 13-03, job rows in 13-04), plus Go tests.
- Recommendation: notifications through API flow steps, digest rows and
- The
RegisterEvent.Payloadcontents (A3).- Recommendation: a copy of the input without
passwordandpassword_confirmation. - RESOLVED: Payload strips
passwordandpassword_confirmation(user decision).
- Recommendation: a copy of the input without
- x/text for CSV decoding (A5).
- Recommendation: approve the direct import. It is already in the module graph.
- RESOLVED: the direct
golang.org/x/textimport 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 |
| 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 |
| 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) |
| 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 |
| 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 vetin 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 --allgreen before/gsd-verify-work.
Wave 0 Gaps
surfconstraint-aware overlap dispatch, plus a test (blocks every wishlist route).congaunregistered-kind insert, plus a test.lagoonprohibitedrule; tide Content-Disposition date and notification publication masks.php_parity.shQUEUE_CONNECTIONoverride;capture-rules.yamlshare:wishlistcapture.fonoteka_reset.php+seedFonotekaCasestates: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 |
| 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/<uid>/<uuid>.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.
- 13-01 Framework gaps and planning docs (summercms.go; fonoteka.go parity scaffolding).
surfoverlap dispatch, thecongaunregistered-kind insert,lagoonprohibited, the tide Content-Disposition date and notification publication masks, each with README and docs updates.php_parity.shQUEUE_CONNECTIONoverride and thecapture-rulesshare:wishlistcapture.- ROADMAP and REQUIREMENTS rewording per D-01, D-02 and D-06, plus Phase 14 gaining wishlist match/apply-release and the credential
/testroutes. - The D-15 manifest fix.
- Tracer: a surf test dispatching the four overlap pairs.
- 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
onboardingflow.
- Tracer:
- 13-03 Wishlist (30 routes).
- Tracer:
GET wishlist/albumson both groups. - The resolver and provisioner, visibility scopes, the subscription and reservation services, the
AlbumDTO.reservationkey, 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.
- Tracer:
- 13-04 CSV import and export (8 routes).
- Tracer:
GET import/csv/{id}404 thenPOST import/csv. - The parser package port with PHP truth tables, the private bucket, the 6 import routes with CAS,
Dispatch/CancelJoband the D-05 seam. - The export with the
fputcsvport on both groups. - Recordings:
nuxt-csv(database queue) plus route cases.
- Tracer:
- 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, privateheader fix, the per-route buckets, the pubfail counter. - Recordings: the
public-anonymousflow.
- Tracer:
- 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.shwith--removalmutations,13-VALIDATION.mdsign-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
fputcsvvs Goencoding/csvcomparison, 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)