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

107 KiB
Raw Blame History

Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes - Research

Researched: 2026-10-02 Domain: Go port of the remaining PHP Płytarium API surface (parity-driven): wishlist, reservations, notifications, CSV import/export, BYOK credentials, onboarding, invitation inspection, anonymous public views, plus their jobs and rate limits Confidence: HIGH for the PHP contract, the Go seams and the framework gaps (all read line by line or probed this session); MEDIUM for sizing and the plan split

Summary

Phase 13 ports 58 routes, all of which already have a recorded fixture, and leaves 4 pending for Phase 14. The work splits into three parts:

  1. Wishlist (30 routes): about 1,480 PHP controller lines plus five services. They reuse the Phase 12 album write path almost entirely.
  2. CSV import (6 routes) and export (2 routes): a 578-line controller over roughly 1,000 lines of parser classes.
  3. Small surfaces (22 routes): notifications, credentials, onboarding, invitation inspection and the public views.

The Go repo already has every model (with unique constraints), the notification writer, the share service, the gates, the invitation consume path and the mail-job pattern. The missing pieces are listed per area below.

The research found eight problems that block or change a decision. They go to the planner, and the first two go to the user before PLAN.md is written:

  1. The router cannot register the wishlist routes as they are. The framework's surf router maps PHP routes straight onto Go net/http ServeMux patterns. Go treats four pairs of wishlist routes as conflicting and panics, so the app fails at boot with surf: route conflict. Examples are GET wishlist/{collectionId}/albums against GET wishlist/albums/{id}, and DELETE wishlist/{collectionId}/subscribe against DELETE wishlist/albums/{id}. PHP's numeric ->where() constraints make each pair disjoint, but ServeMux ignores those constraints. A framework fix in surf is needed (Finding 1).
  2. Enqueuing a job with no registered worker fails while the in-process worker runs. This is the production setting (work_in_serve: true). D-04 and D-08 enqueue CSV and digest jobs with no worker until Phase 14. In that mode conga inserts through the worker client, and River refuses an unknown kind (UnknownJobKindError), so commit, mapping and item-added would return 500 in production while every test passes. Separately, a job of an unregistered kind placed on a queue the worker serves is fetched, fails and is discarded. Both need a framework fix plus a queue-naming rule (Finding 2).
  3. fonoteka.csv.import and fonoteka.csv.match are PHP JobManager labels, not queue names. They become the summer_jobs.label column. PHP pushes these jobs to the default queue. Go must keep the labels verbatim. The queue names D-03 chose still work, but only if no worker serves them (Finding 3).
  4. PHP recordings run with QUEUE_CONNECTION=sync. Under sync, PHP runs the CSV import job inside the commit request and runs the 1800 s digest job at once. The nuxt-csv and nuxt-wishlist recordings therefore need a non-sync queue to show the "queued, not run" state that D-04 and D-13 describe (Finding 4).
  5. Most of the existing fixtures for these routes cannot be replayed. They come from the Phase 2 bootstrap seed: placeholders collide ({{id:token}} stands where an album id belongs) and they use seed_hook: none. The manifest case status also disagrees with the fixture status on 16 in-scope routes, not just the one D-15 names. Every route must be re-recorded from the fonoteka reset, as Phase 12 did (Finding 5).
  6. Three Go-side byte mismatches (Finding 6):
    • Go's public-share headers send Cache-Control: private, no-store, but PHP sends no-store, private, and tide compares the header byte for byte.
    • The CSV export's recorded Content-Disposition header carries the recording date, so a later replay fails.
    • PHP's fputcsv quotes fields that contain a space or a tab. Go's encoding/csv does not.
  7. Every $request->validate() failure is a 500 HTML page in PHP, not a 422 JSON body. This covers the credential stores and the onboarding bootstrap (Finding 7).
  8. There is no tool that records database rows. P11's D-10 tooling records Centrifugo publications only. For D-13 the planner has two routes: make the notification rows visible through API steps in the flows, and assert the wishlist_digest_queue rows in Go tests (Finding 8).

Primary recommendation: six plans, run sequentially:

  1. Framework gaps and planning docs (summercms.go).
  2. Notifications, credentials, onboarding, invitation inspection and the user-plugin hook.
  3. Wishlist.
  4. CSV import and export.
  5. Public views with D-14.
  6. Unit tests, security tests and the gate.

Recordings go into each feature plan. Take Findings 1–3 to the user first: they decide one framework change and the job-name contract D-03 calls costly.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Route matching with PHP ->where() semantics API / Backend (surf router, framework) — Constraint-aware dispatch belongs in the router, not in app handlers (Finding 1)
Wishlist tenancy (own wishlist, visible peers, by-token resolution) API / Backend (classes) Database (scopes, row locks) Server-resolved. The client never selects a wishlist except through numeric ids that are re-gated
Reservation privacy mask API / Backend (serializer) — stateForViewer hides the reserver from the owner until reveal
Notification registry and realtime fan-out API / Backend (write in tx) Centrifugo (publish after commit, lighthouse) P11 D-06: publications are enqueued on the transaction
Purchase mail and digest Background job (conga/River) Mail (postcard) D-07/D-08: enqueued in the write transaction
CSV upload storage Storage (private gocloud blob bucket) — Must never sit under the public uploads prefix
CSV parse, map and export API / Backend — Pure functions ported from PHP, with truth tables
CSV job progress Database (summer_jobs) Background job show reads progress/progress_max/status from the job row
Credentials at rest Database (lagoon.Encrypted, AES-GCM) API / Backend Secret-free responses, encrypted columns
Anonymous public views API / Backend Rate limiter (surf buckets, a pubfail counter) Token resolution plus anti-enumeration
First-run bootstrap API / Backend Database (advisory lock and in-tx count) Replay-safe single owner
Register hook User plugin (event) → fonoteka listener Database Core-plugin contract, D-09

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Phase 13/14 boundary

  • D-01: POST wishlist/albums/{id}/match and apply-release (WishlistReleaseMatchController) move to Phase 14 with the Discogs client (INTG-01), next to the album match/apply-release routes. Their manifest entries stay pending (P6 D-15, no 501 shells). Success criterion 1 and API-03 are reworded at plan time.
  • D-02: Credentials CRUD lands in Phase 13: GET/POST ai-credential, GET/POST/DELETE org-ai-credential, GET/POST discogs-credential (including the shared=true|false mirror into the org credential, owner/admin only, else 403), encrypted storage, FONOTEKA_AI_ORG_LOCK org-lock and the PHP resolution order (AI: admin → backend Settings global model, then org-lock tier, user, org, else error; Discogs: admin → env DISCOGS_TOKEN, then user, org). The two live-call routes ai-credential/test and discogs-credential/test stay pending for Phase 14 (Discogs client and AI adapters). Success criterion 4 and API-06 are reworded at plan time.
  • D-03: All 7 CSV routes are ported in Phase 13 (POST import/csv 202 with throttle:10,1, GET import/csv/{id}, PATCH .../mapping, PATCH .../rows/{rowId}, POST .../commit, POST .../cancel, GET export/csv on both groups). commit (after the preview→importing compare-and-swap, idempotent 202 on replay) and non-canonical mapping enqueue real River jobs whose kinds, queues (fonoteka.csv.import, fonoteka.csv.match) and args mirror PHP's AlbumCsvImportJob/AlbumCsvMatchJob. The job bodies are JOBS-02 in Phase 14. cancel marks the job rows canceled as PHP does; show reads progress from the job table. — Reversibility: costly — the job kind names and args become the contract that Phase 14 workers consume and that queued rows in a live DB already carry.
  • D-04: Until Phase 14, no worker is registered for the CSV import/match kinds. The queued rows simply wait and show reports the state PHP reports before its worker picks the job up. No fake success, no stub worker.
  • D-05: The row-edit branch with selected_discogs_id (PHP calls DiscogsClient::getRelease inline) is held behind a narrow seam that Phase 14 fills. The rest of the row-edit route is ported. How that branch behaves in the meantime (and which recorded case stays pending) is decided at planning, with the constraint that it never fabricates release data.
  • D-06: Notifications: PHP has no prune route; pruning is the fonoteka:prune-notifications console command, already in Phase 14. Success criterion 2 and API-04 drop "prune" at plan time. The 4 routes (notifications capped at 50 newest-first, unread-count, read-all, {id}/read) are ported.

Wishlist mail and digest

  • D-07: wishlist/albums/{id}/purchase keeps PHP's DB effects (move via collection_id update, releaseForAlbum, bell notifications, reserver notification) but subscriber mail is sent by a River job enqueued in the purchase transaction, sent only after commit (P12 D-13 pattern). PHP sends it inline; response bodies and DB state still match.
  • D-08: Adding a wishlist item writes bell notifications immediately and upserts the wishlist_digest_queue row exactly as WishlistDigestQueue::enqueue does (first enqueue in a window creates the row and enqueues the 1800 s delayed digest job; later ones bump item_count). The delayed River job is enqueued with the PHP-equivalent kind and args but has no worker until Phase 14, which adds the worker, the digest mail and the queue-row delete (JOBS-03).

User plugin register hook (core plugin change, signed off)

  • D-09: sm-user-plugin's RegisterEvent gains a generic Payload map[string]any carrying the raw register input, mirroring Winter's golem15.user.register payload. Additive and non-breaking: existing listeners compile unchanged, the user plugin learns nothing about invitations, and its README is updated in the same change. The user signed off on this core-plugin change on 2026-10-02. — Reversibility: costly — once other plugins read Payload, removing or reshaping it breaks them across every project using the user plugin.
  • D-10: fonoteka registers a listener that ports Plugin.php:180: hash Payload["invitation_token"]; if it matches a valid invitation for the user's email, upsert PendingInvitationRegistration (consumed later by the existing guardPendingInvitation); otherwise provision a collection.
  • D-11: onboarding/bootstrap registers the first user through the user plugin's exported registration path so password hashing, the register event and JWT issuance are identical to /register. If a needed function is not exported, it is exported additively (same sign-off as D-09). Bootstrap keeps PHP's validation (org_name, email, password), the cache-lock race guard and the 409 on replay.

Parity evidence

  • D-12: New recordings against the isolated PHP instance with the Phase 2 tide capture rules (private 0600 vars, no live tokens in git):
    • nuxt-wishlist: create item → share show/update/regenerate → subscribe by token and by collection → reserve as a second user → reveal → purchase → settings → household → peer albums.
    • nuxt-csv: store → show → mapping → row edit → commit (to its 202 and the queued job row) → cancel, then CSV export on both groups. Jobs do not run (D-04).
    • public-anonymous: public/{token} and public-wishlist/{token} resolve, albums, album detail, a bad token, and the pubfail:<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 with invitation_token.
    • Plus every distinct error status/body per route, with envelopes reproduced per endpoint (P7 D-13).
  • D-13: Side effects of purchase and item-added are verified by diffing recorded notifications and wishlist_digest_queue rows and the Centrifugo publications (P11 D-10 tooling, timestamp/actor normalised). Mail recipients, locale and template are asserted in Go tests through postcard's memory driver.

Fixes without a decision (parity rule)

  • D-14: The public group's resolve routes (public/{token}, public-wishlist/{token}) get PHP's inline throttle:10,1 only. fonoteka-public-token + fonoteka-public-ip apply only to the albums index/show routes. Currently routes.go applies both buckets at group level.
  • D-15: The manifest case for GET invitations/{token} expects 404 while its recorded fixture is 200 {"data":{"state":"unavailable"}}. The manifest is corrected to the fixture.

Carried forward (locked earlier)

  • C-01: One handler per route, mounted on both groups where PHP mirrors it, with PHP's exact ->where() constraints, inline throttles and exactly one inv.scope:read|write per token-group route (P12 D-01, D-10, D-26).
  • C-02: Error bodies as production PHP serves them (APP_DEBUG=false), including Winter HTML 404 pages where PHP throws HttpException (notifications {id}/read, reserve DELETE, reveal, subscriptions) (P12 D-21). CSV errors keep {"result":"error","code","message"}; public 404/429 keep {"error":"Not found"} / {"error":"Too many requests"}.
  • C-03: Response DTOs from ported Serialize* functions, [] not null, Carbon +00:00, pagination envelope without links (P5 D-08, P6 D-17).
  • C-04: Write paths go through ported fill boundaries; the request-DTO fuzz covers every write endpoint of this phase (P12 C-02).
  • C-05: Mail via River job in the write transaction; raw tokens in job args encrypted with the app key (P12 D-13, D-23).
  • C-06: Public tokens: 16 chars [0-9a-zA-Z], LOWER() lookup plus constant-time compare, no route regex, PublicShareHeaders (X-Robots-Tag: noindex, nofollow, Cache-Control: no-store, private). public-wishlist mirrors public collection with kind='wishlist' and no reservations.
  • C-07: Unported routes stay pending; pending never counts as passing (P2, P6 D-15).

Claude's Discretion

  • Whether the wishlist item-added branch extends the existing albumAddedCallback GORM hook or is an explicit write-service call, provided every create path (JWT, token-group POST, any bulk path) triggers it exactly once and only after commit.
  • Handler file layout and how the ~1,480 lines of wishlist controllers and the 578-line CSV controller are split across Go files.
  • Job kind names for purchase mail and digest (must be stable once chosen, see D-03 reversibility).
  • The interim behaviour of the selected_discogs_id row-edit branch (D-05), within its constraint.
  • Which error cases are recorded versus Go-tested with bodies from PHP source where recording is impractical. Recording is the default.
  • Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent (this phase touches public tokens, credentials encryption and authorization).

Deferred Ideas (OUT OF SCOPE)

  • Wishlist match/apply-release, credential /test routes, CSV job workers, the selected_discogs_id row-edit branch, the digest worker and mail, and fonoteka:prune-notifications — all Phase 14.

Reviewed Todos (not folded)

  • redacting-slog-handler.md — relevant to credentials but a framework logging concern; stays a standalone todo.
  • backend-admin-api-tokens.md, refresh-fonoteka-readme.md, rewrite-summercms-readme.md, per-module-readmes-after-nest.md, readme-go-fences-src.md — keyword matches only, unrelated to this phase's routes. </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 vet and go test ./... stay green at every commit.
  • Compiled plugins. No runtime plugin loading.
  • API parity is the acceptance test. Do not improve response shapes.
  • Two repositories. The framework is summercms.go, the app is fonoteka.go, and the user plugin is the sm-user-plugin submodule at fonoteka.go/plugins/golem15/user.
    • Framework READMEs never name the app.
    • Planning docs stay in summercms.go/.planning.
    • Manage the submodule with ssu. The commit lands in the submodule first, then a pointer bump goes into fonoteka.go, as in Phase 12 (f60c3af chore(12-05): bump sm-user-plugin ...).
  • Documentation. Any exported-API, config or CLI change to a modules/ package updates that module's README.md and the affected docs/ pages in the same change. This applies here to surf, conga, lagoon and tide.
    • Checkers: go test ./cmd/summer -run TestDocsTree and summer docs:build --check.
    • Every identifier named in a doc must exist.
  • Commits.
    • No co-author tags. This follows the user's global instruction and the project CLAUDE.md, and it overrides the harness attribution reminder.
    • One logical change per commit.
    • Planning docs and code go in separate commits.
  • Core plugin contracts. The user, blog, pages and payment plugins must not break. D-09 and D-11 are signed-off additive changes to the user plugin, and nothing else in the user plugin may change.
  • GSD. Edits go through a GSD workflow.

Critical Findings (read before planning)

Finding 1: Four wishlist route pairs crash the router at boot (framework gap in surf)

surf compiles each route straight to mux.Handle(rt.method+" "+rt.path, h) and turns a ServeMux panic into an error [VERIFIED: summercms.go/modules/surf/router.go:346-367]:

	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) conga inserts kinds that have no registered job through the insert-only client, which never checks kinds. The alternative is setting SkipUnknownJobCheck: true on the worker client. Add a test: "Enqueue/Dispatch of an unregistered kind succeeds while a worker runs". Update the README.
  • (b) Phase 13 jobs without a worker use queues that nothing configures or registers: fonoteka.csv.import, fonoteka.csv.match and a digest queue such as fonoteka.wishlist.digest. Never default or mail, and do not add them to config/queue.yaml until Phase 14 registers their workers.

Finding 3: The CSV "queues" in D-03 are PHP job labels

PHP dispatches through Apparatus JobManager::dispatch($job, $label, $parameters, $delay) [VERIFIED: apparatus/classes/JobManager.php:59-97]. That call inserts a golem15_apparatus_jobs row with that label, progress_max = count and metadata = json_encode($metadata), then calls $this->queue->push($job). That push goes to the default queue, because the job classes declare no $queue [VERIFIED: jobs/AlbumCsvImportJob.php:23-34]. The calls:

  • 'fonoteka.csv.match' with ['count' => (int) $import->row_count, 'metadata' => ['csv_import_id' => $import->id]] [VERIFIED: CsvImportApiController.php:167-174]
  • 'fonoteka.csv.import' with the same parameters [VERIFIED: CsvImportApiController.php:306-313]
  • 'wishlist_digest', [], 1800 [VERIFIED: models/WishlistDigestQueue.php:37-42]

The returned id is stored in match_job_id / import_job_id. show reads progress, progress_max and status from that row [VERIFIED: CsvImportApiController.php:543-558]. cancel sets is_canceled = true and status = STOPPED (4) [VERIFIED: CsvImportApiController.php:348-358, JobManager.php:222-233, contracts/JobStatus.php const STOPPED = 4;].

Mapping onto Go: conga.Dispatch writes the same row into summer_jobs. Its statuses equal the Apparatus constants [VERIFIED: conga/record.go:9-23]. Its nil metadata stores "", as PHP's json_encode('') does. conga.CancelJob sets is_canceled and StatusStopped and cancels the River job [VERIFIED: conga/conga.go:321-346].

Action: keep D-03's queue names. Set the summer_jobs.label verbatim to fonoteka.csv.import, fonoteka.csv.match and wishlist_digest. Put this correction to the user, because D-03 calls the names "queues" and marks them costly. See the jobs table.

Minor difference: PHP's JobManager::dispatch reads \Auth::getUser(), Winter's session auth, which is null on JWT requests (Plugin.php:118-133 explains this). PHP rows therefore get user_id = NULL, while conga.Dispatch stores the bouncer principal [VERIFIED: conga.go:166-176]. Replays never see the column. Record it as a known difference, or dispatch with a principal-free context if the user wants exact DB parity.

Finding 4: Recording with QUEUE_CONNECTION=sync runs the jobs D-04 and D-13 expect to stay queued

php_parity.sh exports export QUEUE_CONNECTION=sync [VERIFIED: parity/php_parity.sh:41]. Under sync:

  • commit runs AlbumCsvImportJob inside the request, so the import is done before the 202 returns.
  • WishlistDigestQueue::enqueue runs WishlistDigestJob at once, because the sync driver's later() does not delay [ASSUMED]. That job deletes the queue row and mails.

Album broadcasts go through the queue too [VERIFIED: websockets/traits/BroadcastableModel.php:221-222]. Notification publications do not: they are synchronous HTTP calls to CentrifugoClient::publish [VERIFIED: websockets/classes/CentrifugoClient.php:90-108].

Action (plan 13-01):

  • php_parity.sh honours QUEUE_CONNECTION="${QUEUE_CONNECTION:-sync}".
  • nuxt-csv and nuxt-wishlist are recorded with QUEUE_CONNECTION=database. Winter ships the jobs migrations 2014_10_01_000010_Db_Jobs.php and successors [VERIFIED: ls modules/system/database/migrations].
  • The purchase step's album updated broadcast, which is queued, is recorded separately with parity:broadcasts under sync, in a state with no email-enabled subscriber. The notification:* publications are captured under either queue mode.

Finding 5: The existing fixtures cannot be replayed

The Phase 2 bootstrap recordings contain scrubber collisions: {{id:token}} stands in the CSV export body where an album id belongs, {{id:genre}} stands as a public album's id, and {{id:wishlist-album}} as an artist id. These routes carry no seed_hook. The manifest case status also disagrees with the recorded fixture status on 16 in-scope routes:

  • POST wishlist/albums: manifest 200, fixture 201.
  • POST wishlist/token/{token}/subscribe and the token-group POST wishlist/albums: manifest 200, fixture 201.
  • Recorded 404 although the manifest expects 200: GET/POST wishlist/{collectionId}/subscribe, purchase, reserve, reveal, GET/PUT wishlist/albums/{id}, both peer-album routes, both public albums/{id} routes, and the token-group PUT wishlist/albums/{id}.
  • DELETE org-ai-credential: manifest 404, fixture 200.
  • GET invitations/{token}: manifest 404, fixture 200 (the case D-15 names).

[VERIFIED: manifest/fixture comparison script this session over parity/manifest.yaml and fixtures/routes/*.]

Action: like Phase 12, re-record every in-scope case from the fonoteka reset (seed_hook: fonoteka, PARITY_CASE states), with case plus named error cases. Fix each manifest status from its new fixture. D-15 is one instance of this general fix.

Finding 6: Three byte-level mismatches the replay would catch

  1. Public headers. PHP sends Cache-Control: "no-store, private" [VERIFIED: fixtures/routes/GET___fonoteka_api_v1_public_{token}_public_share.yaml:14]. Go sets dst.Set("Cache-Control", "private, no-store") [VERIFIED: middleware/public_share_headers.go:32]. tide compares Cache-Control as exact strings [VERIFIED: tide/diff.go:43-90]. Fix the Go string to no-store, private. C-06 already writes it that way.
  2. Export filename date. The export fixture records Content-Disposition: "attachment; filename=plytarium-kolekcja-2026-09-17.csv", and tide compares Content-Disposition [VERIFIED: fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml:15, tide/diff.go:43-50]. Add a tide normalizer that masks the date in plytarium-kolekcja-YYYY-MM-DD.csv, or give the Go export an injectable clock and freeze it in the replay.
  3. CSV quoting. PHP's fputcsv($o, ..., ',', '"', '') quotes fields that contain a space or a tab. Go's encoding/csv does not [VERIFIED: probe this session — PHP: "Kind of Blue",LP,"a b","x""y",,=SUM(1),plain,semi;colon,café; Go: Kind of Blue,LP,a b,"x""y",,=SUM(1),plain,semi;colon,café]. PHP writes null as an empty field, false as an empty field and true as 1 [VERIFIED: same probe ,1959,0,,1]. Port fputcsv as a small writer and do not use encoding/csv.Writer.

Finding 7: $request->validate() failures are Winter 500 pages

With APP_DEBUG=false, a ValidationException is rendered as the generic "Błąd strony" page with status 500 and Content-Type: "text/html; charset=UTF-8", not as a 422 JSON body [VERIFIED: fixtures/routes/POST___fonoteka_api_v1_household_invitations_jwt__missing-email.yaml (status: 500, <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::store and OrgAiCredentialController::store: $request->validate and ValidationException::withMessages.
  • DiscogsCredentialController::store: the same two calls, plus the regex message.
  • OnboardingController::bootstrap: $request->validate, including unique:users.

The controllers that use Validator::make instead return the 422 JSON body {"error":"Validation failed","errors":...}: the wishlist album, settings, share and public index controllers.

Go already has winter_500.html [VERIFIED: ls controllers/api/winter_500.html]. Record at least one failing case per store route.

Finding 8: No DB-row capture tool exists for D-13

P11 D-10 is the Centrifugo publication recorder only: "Phase 2 tide tooling is extended to subscribe to the PHP stack's Centrifugo ... and store the publications as goldens" [VERIFIED: 11-CONTEXT.md:53]. parity/db_capture.go reads only user reset and activation codes [VERIFIED: db_capture.go:17-59].

Action:

  • Notification rows. Make them HTTP-visible: the nuxt-wishlist flow adds GET notifications steps as each recipient after item-add, purchase and reveal. Their bodies are the rows {id,type,payload,read_at,created_at}.
  • wishlist_digest_queue rows and job rows. Assert in Go integration tests from PHP source semantics. The optional alternative is a tinker dump of the parity SQLite into a golden, which the planner should only build if the user insists on a recorded row diff.
  • Publication masking. notification:new publications carry id and created_at under $.data.payload [VERIFIED: NotificationService.php:273-278]. The tide normalizer masks only timestamp, actor, captured ids and $.data.payload.album.*_at [VERIFIED: tide/centrifugo_golden.go:278-327]. Either extend it with $.data.payload.created_at → {{datetime}} and a notification-id mask, or align the notification id sequence on both sides, as Phase 12 did by lifting the collection sequences.

Finding 9: Smaller framework gaps

  • lagoon lacks prohibited. The rule arity table has no prohibited and no unique [VERIFIED: lagoon/validate_rules.go:35-43]. The wishlist album rules need 'condition' => 'prohibited' and 'shelf' => 'prohibited' [VERIFIED: WishlistAlbumApiController.php:296, 304]. Laravel defines validateProhibited as ! $this->validateRequired(...) and it is not implicit [VERIFIED: vendor/laravel/framework (9.x-dev) ValidatesAttributes.php:1821-1824, Validator.php:204-224]. Neither Winter's pl nor its en validation catalog has a prohibited key [VERIFIED: grep]. The message is therefore probably the literal key validation.prohibited [ASSUMED]. Record it.
  • unique:users for bootstrap needs no framework rule: any failure renders the same 500 page (Finding 7), so a Go-side existence check is enough.
  • The public search mode is missing. Go SearchAlbums is user- and token-scoped (ScopedAlbums(ctx, db, p.UserID, p.Token, p.CollectionID)) and hard-codes the authenticated text fields [VERIFIED: classes/album_search.go:22-30, 317-320]. The public index needs TEXT_FIELDS_PUBLIC: 'query_by' => 'name,artist_display,style_names,genre_name,track_titles', 'query_by_weights' => '10,10,5,5,3', 'sql_like' => ['name', 'artist_display', 'track_titles'], 'sql_like_relations' => ['artists.name'] [VERIFIED: AlbumSearchService.php:60-65]. It also needs a collection-only scope with no ratings join. This is an app-level change in classes/album_search.go.

Route Inventory (62 in scope: 58 ported, 4 stay pending)

Everything below is status: pending in parity/manifest.yaml today, and every route has a recorded fixture that exists on disk [VERIFIED: manifest scan]. "Fx" is the status recorded in the old fixture, which must be re-recorded (Finding 5).

PHP prefixes:

  • JWT group (J): /_fonoteka/api/v1, ['jwt.auth','inv.must-change-password','bindings'] [routes.php:74-76].
  • Token group (T): /api/v1/fonoteka, ['bindings','throttle:fonoteka-api-token'] plus one inv.scope per route [routes.php:449-451].

In the Go JWT group, locale.from-principal sits between those middlewares (routes.go:40).

Notifications (4, JWT) — NotificationApiController (68 lines)

Method Path Constraints / throttle PHP Fx Plan
GET notifications — @index (limit 50, orderByDesc('id')) 200 13-02
GET notifications/unread-count — @unreadCount 200 13-02
POST notifications/read-all — @markAllRead 200 13-02
POST notifications/{id}/read id [0-9]+ @markRead (HttpException 404 → Winter page) 404 13-02

Credentials (9 JWT, 7 ported) — AiCredentialController (176), OrgAiCredentialController (153), DiscogsCredentialController (175)

Method Path PHP Fx Plan
GET ai-credential @show 200 {"configured":false} 13-02
POST ai-credential @store 200 {"configured":true,"provider":"openai"} 13-02
POST ai-credential/test @test (live AI call) 200 pending → Phase 14 (D-02)
GET org-ai-credential @show 200 13-02
POST org-ai-credential @store 200 13-02
DELETE org-ai-credential @destroy 200 (manifest says 404) 13-02
GET discogs-credential @show 200 {"configured":false,"source":null,"shared":false} 13-02
POST discogs-credential @store 200 {"configured":true,"source":"user","shared":false} 13-02
POST discogs-credential/test @test (live Discogs) 200 pending → Phase 14 (D-02)

Onboarding, invitation inspection (3, public)

Method Path Group / middleware PHP Fx Plan
GET onboarding/status ['bindings','throttle:10,1'] (routes.php:351-356) OnboardingController@status 200 13-02
POST onboarding/bootstrap same group OnboardingController@bootstrap (111 lines) 409 13-02
GET invitations/{token} ungrouped, ['bindings','throttle:10,1'], no constraint (routes.php:361-364) InvitationApiController@inspect 200 (manifest says 404, D-15) 13-02

Wishlist JWT (28, 26 ported) — routes.php:134-225

Method Path Constraints / throttle PHP controller@method Fx Plan
GET wishlist/share — WishlistShareController@show (142) 200 13-03
PUT wishlist/share — @update 200 13-03
POST wishlist/share/regenerate throttle:10,1 @regenerate 200 13-03
GET wishlist/household — WishlistHouseholdController@index (121) 200 13-03
GET wishlist/subscribers — WishlistSubscriptionController@subscribers (166) 200 13-03
GET wishlist/settings — WishlistSettingsController@show (93) 200 13-03
PUT wishlist/settings — @update 200 13-03
GET wishlist/subscriptions — @subscriptions 200 13-03
GET wishlist/token/{token}/subscribe none (string) WishlistSubscriptionController@statusByToken 200 13-03
POST wishlist/token/{token}/subscribe none @subscribeByToken (201) 201 13-03
DELETE wishlist/token/{token}/subscribe none @unsubscribeByToken (404 page) 404 13-03
GET wishlist/{collectionId}/subscribe collectionId [0-9]+ @statusByCollection 404 13-03
POST wishlist/{collectionId}/subscribe collectionId [0-9]+ @subscribeByCollection (201) 404 13-03
DELETE wishlist/{collectionId}/subscribe collectionId [0-9]+ @unsubscribeByCollection 404 13-03
POST wishlist/albums/{id}/purchase id [0-9]+ WishlistAlbumApiController@purchase (362) 404 JSON 13-03
POST wishlist/albums/{id}/reserve id [0-9]+ WishlistAlbumReservationController@reserve (175) → 201 404 page 13-03
DELETE wishlist/albums/{id}/reserve id [0-9]+ @cancel 404 page 13-03
POST wishlist/albums/{id}/reveal id [0-9]+ @reveal 404 page 13-03
GET wishlist/albums — WishlistAlbumApiController@index 200 13-03
POST wishlist/albums — @store (201, duplicate key) 201 13-03
GET wishlist/albums/similar — @similar ({"wishlist":[],"collection":[]}) 200 13-03
GET wishlist/albums/{id} id [0-9]+ @show 404 JSON 13-03
PUT wishlist/albums/{id} id [0-9]+ @update 404 JSON 13-03
DELETE wishlist/albums/{id} id [0-9]+ @destroy 404 JSON 13-03
POST wishlist/albums/{id}/match id [0-9]+ WishlistReleaseMatchController@match 404 pending → Phase 14 (D-01)
POST wishlist/albums/{id}/apply-release id [0-9]+ @applyRelease 404 pending → Phase 14 (D-01)
GET wishlist/{collectionId}/albums collectionId [0-9]+ WishlistPeerAlbumController@index (137) 404 page 13-03
GET wishlist/{collectionId}/albums/{albumId} both [0-9]+ @show 404 page 13-03

Wishlist on the token group (4) — routes.php:507-510

Method Path Scope PHP Fx Plan
GET wishlist/albums inv.scope:read WishlistAlbumApiController@index 200 13-03
POST wishlist/albums inv.scope:write @store 201 13-03
PUT wishlist/albums/{id} inv.scope:write, id [0-9]+ @update 404 13-03
DELETE wishlist/albums/{id} inv.scope:write, id [0-9]+ @destroy 404 13-03

None of these is mounted on the token group: show, purchase, similar, match, apply-release, reserve, reveal, rating, share, settings, subscriptions or peer albums [VERIFIED: routes.php:501-506 comment and the route list].

CSV (8) — CsvImportApiController (578), CsvExportApiController (78)

Method Path Group Constraints / throttle PHP Fx Plan
POST import/csv J throttle:10,1 @store → 202 422 csv_unreadable 13-04
GET import/csv/{id} J id [0-9]+ @show 404 {"error":"Nie znaleziono importu."} 13-04
PATCH import/csv/{id}/mapping J id [0-9]+ @updateMapping 404 13-04
PATCH import/csv/{id}/rows/{rowId} J id, rowId [0-9]+ @updateRow 404 13-04
POST import/csv/{id}/commit J id [0-9]+ @commit → 202 404 13-04
POST import/csv/{id}/cancel J id [0-9]+ @cancel 404 13-04
GET export/csv J — CsvExportApiController@download 200 text/csv 13-04
GET export/csv T inv.scope:read same 200 text/csv 13-04

Public views (6) — PublicShareApiController (403), PublicShareHeaders (77)

The group runs ['bindings', PublicShareHeaders::class] [routes.php:399-428].

Method Path Route middleware Where PHP Fx Plan
GET public/{token} throttle:10,1 none on token @resolve(…,'collection') 200 13-05
GET public/{token}/albums throttle:fonoteka-public-token, throttle:fonoteka-public-ip — @index 200 13-05
GET public/{token}/albums/{id} same two buckets id [0-9]+ @show 404 {"error":"Not found"} 13-05
GET public-wishlist/{token} throttle:10,1 — @resolve(…,'wishlist') 200 13-05
GET public-wishlist/{token}/albums two buckets — @index(…,'wishlist') 200 13-05
GET public-wishlist/{token}/albums/{id} two buckets id [0-9]+ @show(…,'wishlist') 404 13-05

Count check: the 62 routes are 28 + 4 + 3 wishlist, 4 notifications, 6 + 2 CSV, 9 credentials, 2 onboarding, 1 inspection and 3 public collection routes. CONTEXT's breakdown ("5 personal-token", "CSV 7") moves the token-group export into the wishlist bucket. The total of 62 and the 58/4 split are correct. After this phase the corpus has 157 ported routes (99 + 58), so the parity expectedPortedRoutes = 99 [VERIFIED: parity/parity_test.go:29] becomes 157. Ten pending routes remain outside Phase 13: GET /api/v1/fonoteka/me, two oauth-identities routes, five album match/recognize/import routes and two token-group recognize/cover-price routes.

Per-Area Analysis

Notifications

  • PHP. The controller is above. index maps {id, type, payload, read_at, created_at} with toIso8601String. markRead scopes the update by user_id and turns zero rows into HttpException(404) [VERIFIED: NotificationApiController.php:17-67]. Eloquent-builder update() also bumps updated_at [ASSUMED].
  • Go has:
    • models.Notification with Payload lagoon.Jsonable[map[string]any] [VERIFIED: models/notification.go].
    • classes.WriteNotification, which publishes notification:new then notification:count on the transaction.
    • The type constants "album_added", "invitation_accepted", "wishlist_item_added", "wishlist_item_purchased", "reservation_revealed" [VERIFIED: classes/notification_service.go:23-29].
  • Missing: the four handlers. Read the payload as json.RawMessage so the PHP key order survives: tide's diff ignores key order, but the claim here is byte compatibility.

Credentials

  • PHP behaviour [VERIFIED: the three controllers]:
    • show returns {configured:false} or {configured,provider,model,base_url}.
    • store validates provider: required|in:claude,openai, api_key: nullable|string, model: nullable|string|max:255, base_url: nullable|url. A missing api_key reuses the stored key, otherwise it throws ValidationException::withMessages(['api_key' => ['The api key field is required.']]).
    • updateOrCreate writes only the validated keys present in the request (absent ≠ null).
  • Org store quirk: the reused key comes from the caller's UserAiCredential, not from the org credential [VERIFIED: OrgAiCredentialController.php store]. Port it as is.
  • Org guards: no organisation → 404 {"error":"No organisation"} on show and destroy. Store provisions an organisation (OrgProvisioner::provisionFor), then canManage → 403 {"error":"Forbidden"}.
  • Discogs:
    • The token is trimmed. The regex is /^[A-Za-z0-9_\-]{10,255}$/, with a custom message golem15.fonoteka::lang.discogs.token_invalid_format.
    • shared && !canManage → 403 with discogs.shared_forbidden.
    • The user and org writes happen in one transaction.
    • status() = {configured: DiscogsGate::allows, source: resolver source, shared: org credential exists}.
  • Go has:
    • The four credential models with lagoon.Encrypted and json:"-".
    • CredentialFillFields = []string{"provider", "model", "base_url"} [VERIFIED: classes/credential_write_service.go:16] and its fuzz test.
    • IsSiteAdmin, CanManageOrg, AIAllowed, ResolveDiscogsConfig and DiscogsAllowed [VERIFIED: classes/gates.go].
    • ProvisionOrgFor [VERIFIED: classes/org_provisioner.go:28].
    • The config keys golem15.fonoteka.ai_org_lock and golem15.fonoteka.discogs.token [VERIFIED: gates.go consts; config/config.yaml].
  • Missing:
    • The seven handlers.
    • The Discogs lang strings for shared_forbidden and token_invalid_format.
    • The AiConfigResolver port (PHP AiConfigResolver.php, 72 lines). Its only callers are /test and recognize (Phase 14), so it is optional here. D-02's resolution order is fully observable in Phase 13 through AIAllowed/me/context and Discogs status().
    • AIAllowed does not have PHP AiGate's site-admin branch (Settings::getVisionModel() !== null) [VERIFIED: AiGate.php vs gates.go:59-76]. Leave it for Phase 14 unless a recorded case needs it.
  • Ciphertext compatibility: PHP casts 'api_key' => 'encrypted' (Laravel's AES-CBC envelope) [VERIFIED: models/UserAiCredential.php]. Go uses AES-256-GCM with its own format byte [VERIFIED: lagoon/encrypted.go]. Existing PHP ciphertext is unreadable to Go. This is a Phase 15 data-migration concern, not an API concern.

Onboarding, invitation inspection and the register hook (D-09..D-11)

PHP bootstrap [VERIFIED: OnboardingController.php:43-110]:

  • status = {"needs_onboarding": User::count() === 0}.
  • bootstrap:
    • A count before the lock → 409 {"error":"Onboarding already completed"}.
    • validate(['org_name' => 'required|string|max:255', 'email' => 'required|email|unique:users', 'password' => 'required|min:8']), which fails as a 500 page (Finding 7).
    • Cache::lock('fonoteka:onboarding:bootstrap', 10)->block(5, ...) wraps DB::transaction.
    • Inside the transaction: a recount, Organisation::create(['name' => ..., 'slug' => Str::slug(org_name)]), Auth::register($reg, true, false), organisation_id plus organisation_role = 'owner', Event::fire('golem15.user.register', [$user, $reg]), JWTAuth::attempt, then {token, user: getApiArray()}.
    • A lock timeout → 409.

PHP inspection: inspect → {"data":{"state":"unavailable"}} or {"data":{"state":"pending","collection_name":...}} [VERIFIED: InvitationService.php:26-37].

PHP register listener [VERIFIED: Plugin.php:180-205]: hashes invitation_token with sha256 and matches token_hash, accepted_at IS NULL, revoked_at IS NULL, expires_at > now() and LOWER(email) = strtolower(trim(user.email)). A match → PendingInvitationRegistration::updateOrCreate(['user_id'], ['invitation_id']). Otherwise → CollectionProvisioner::provision($user).

Go user plugin today [VERIFIED: plugins/golem15/user/classes/events.go:19-22]:

// RegisterEvent is fired after a user row is created. No listener consumes it yet.
type RegisterEvent struct {
	User *models.User
}

controllers.Register handles everything inline:

  • validation, then bouncer.HashPassword, then fields["password"] = hashed, then lagoon.Fill, then Create;
  • _ = app.Events.Fire(r.Context(), &classes.RegisterEvent{User: user}) — the error is ignored and there is no transaction [VERIFIED: user/controllers/api_controller.go:602-703, line 671];
  • then the unexported mintFor and apiArray [api_controller.go:813-852].

Nothing reusable is exported for D-11.

Additive exports to recommend in sm-user-plugin:

  • RegisterEvent.Payload map[string]any (D-09). Fill it with a copy of the request fields with password and password_confirmation removed. By the time the event fires, fields["password"] holds the bcrypt hash and password_confirmation still holds the plaintext. Removing both keeps secrets out of every listener. This deviates from "raw input" and must be confirmed (Assumption A3).
  • An exported registration core, RegisterUser(ctx, app, db, fields, opts): validate, hash, fill, create, fire RegisterEvent. /register and the bootstrap share it.
  • Exported IssueToken (mintFor) and APIArray (apiArray).
  • The README updated in the same commit.

Do not change /register's behaviour or body. The Go user model has Organisation [VERIFIED: user/models/organisation.go].

Go fonoteka has:

  • guardPendingInvitation (consume side) [VERIFIED: classes/active_collection.go:269].
  • ProvisionCollection(ctx, tx, user) [VERIFIED: classes/collection_provisioner.go:26].
  • invitationTokenHash [VERIFIED: classes/invitation_service.go:115-118].
  • A unique user_id on pending_invitation_registrations (CONSTRAINT fonoteka_pending_invite_user_unique UNIQUE (user_id)) [VERIFIED: updates/20_remaining.go:58]. ON CONFLICT (user_id) DO UPDATE is therefore the right upsert.
  • models.Slug [VERIFIED: models/slug.go:21]. Check it against Str::slug for org names.

Missing: the listener in Boot (pattern: app.Events.Listen[*userclasses.GetApiArrayEvent] at plugin.go:75), the onboarding handlers, the inspect handler and InspectInvitation.

Race guard: use SELECT pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap')) inside the transaction, plus the in-transaction COUNT(*) FROM users WHERE deleted_at IS NULL, instead of a cache lock. A loser blocks, then sees count > 0 → 409.

Wishlist (30 routes)

PHP services [VERIFIED: each file]:

Service Lines Responsibility
ActiveWishlistResolver 35 Own kind='wishlist' collection or provision
WishlistProvisioner 49 FOR UPDATE on the user row then the existing wishlist; creates name 'Moja lista życzeń'
WishlistSubscriptionService 247 subscribe, unsubscribe, subscribersOf, followerCountOf, stateFor, subscriptionsFor
AlbumReservationService 187 reservableWishlistIds, reserve, cancel, reveal, releaseForAlbum, stateForViewer
CollectionShareService::resolvePublic / stateFor 236 Token lookup and share state
NotificationService 284 notifyWishlistItemAdded, notifyWishlistItemPurchased, notifyReservationRevealed, mailWishlistPurchased
AlbumSimilarityFinder 67 Similar-album lookup
AlbumWriteService::moveToCollection — The purchase move

Service details:

  • WishlistSubscriptionService:
    • subscribe is updateOrCreate and sets subscribed_at = now().
    • subscribersOf adds synthetic household peers with ws_enabled and email_enabled true.
    • followerCountOf counts the union of peers and subscribers, excluding the owner.
    • stateFor carries forced for household peers.
    • subscriptionsFor lists household wishlists sorted by owner name, then other subscriptions.
  • AlbumReservationService:
    • reserve takes FOR UPDATE on the album, checks for an existing reservation (→ HttpException(409)), then creates.
    • reveal is idempotent, writes revealed_at and the reservation_revealed bell notification only, no mail.
    • releaseForAlbum deletes and returns the reserver id.
    • stateForViewer hides reserved_by from the owner before reveal.
  • CollectionShareService::resolvePublic: the 16-character regex, whereRaw('LOWER(public_token) = ?'), public_enabled, kind, then hash_equals. stateFor builds the /w/ or /k/ path.
  • AlbumSimilarityFinder: name and artist LIKE with addcslashes('\\%_'), an optional year, the photos embed, orderBy('name'), limit 6.
  • AlbumWriteService::moveToCollection: sets collection_id and saves, then releaseForAlbum, then notifyWishlistItemPurchased($album, $from, $reserverUserId) [AlbumWriteService.php:270-282].

Scopes:

  • Collection::wishlistsVisibleTo($user) = kind='wishlist' AND owner_id IN the owners and editors of every collection the user is a member of, the user included [VERIFIED: models/Collection.php:247-264].
  • accessibleBy already exempts the owner's wishlist from a token pin. Go AccessibleBy mirrors this [VERIFIED: classes/access.go:36-48].

Go has:

  • The models WishlistSubscription, WishlistDigestQueue, AlbumReservation and Collection. The unique constraints are UNIQUE (album_id) on reservations, UNIQUE (user_id, collection_id) on subscriptions and on the digest queue [VERIFIED: updates/10_album_slice.go:216, 20_remaining.go:157, 179].
  • ShareStateFor, EnableShare, DisableShare, RegenerateShare, RenameShare and the /w/ path [VERIFIED: classes/share_service.go:72-140].
  • CreateAlbum, UpdateAlbum, FindDuplicateAlbum, SerializeDuplicate, the cover importer and the album broadcast and search sync hooks.
  • AlbumsAccessibleBy and NotificationActor.
  • albumAddedCallback, which today returns early for any non-real collection (if col.Kind != kindRealCollection { return nil }) [VERIFIED: classes/notification_service.go:248-250].

Missing:

  • A wishlist resolver and provisioner. Nothing in Go matches Moja lista.
  • A wishlistsVisibleTo scope and the reservable ids.
  • The subscription service.
  • The reservation service and the reservation DTO. AlbumDTO has no reservation key; add Reservation *ReservationDTO \json:"reservation,omitempty"``, whose conditional keys are omitted, not nulled.
  • The similarity finder. Use ILIKE with escaping: Postgres LIKE is case-sensitive, while the recording ran on SQLite and production runs on MariaDB _ci.
  • prohibited rules.
  • The household preview: 8 albums with orderBy('name').
  • The purchase move and its side effects, and the purchase mail job with its templates. PHP has wishlist_item_purchased(.htm|-en.htm) with vars albumName and wishlistName, subject "Pozycja na liście życzeń została kupiona" / "A wishlist item has been purchased" and layout plytarium [VERIFIED: views/mail/wishlist_item_purchased*.htm]. Go mail.go registers only the invitation templates [VERIFIED: mail.go:16-29].
  • The item-added branch with the digest upsert.

Recommendation for the item-added discretion: extend albumAddedCallback rather than adding an explicit call.

  • The callback already fires for every album insert (JWT, token group, bulk, and the Phase 14 CSV path) on the insert's transaction. The kind == wishlist branch then calls a new NotifyWishlistItemAdded.
  • Wire a job queue through an atomic pointer, the way realtimeService is wired [notification_service.go:204-209].
  • GORM's default transaction is on (no SkipDefaultTransaction anywhere in modules/ [VERIFIED: grep]), so conga.Dispatch sees a *sql.Tx and joins it [VERIFIED: conga.go:494-500].
  • Publications go out after commit through lighthouse.Emit on the transaction.

Digest upsert: use INSERT ... ON CONFLICT (user_id, collection_id) DO UPDATE SET item_count = golem15_fonoteka_wishlist_digest_queue.item_count + 1, updated_at = NOW() RETURNING (xmax = 0) AS inserted. Dispatch the digest job only when inserted. This is atomic, while PHP's firstOrNew + save is racy.

CSV import and export

PHP [VERIFIED: CsvImportApiController.php]:

  • store:
    • A missing or invalid file, or an extension other than csv/txt → csv_unreadable 422. Size over MAX_BYTES = 5242880 → csv_too_large.
    • CsvAlbumParser::parse. A CsvParseException → error with $e->errorCode and the English exception message (e.g. "CSV file is unreadable."), not the translated one.
    • Invalid canonical rows → validation_failed.
    • Resolve the active collection, store to fonoteka-csv/<uid>/<uuid>.csv on the private disk, create the import with status preview (canonical) or mapping, replaceRows, then 202.
  • show: rows paginated by page and per_page, max(1, min(50)).
  • updateMapping:
    • Requires isBeforeCommit (mapping, preview or failed), else csv_already_committed.
    • Reads column_map (or the whole body), re-parses, cancels the old match job and replaces the rows.
    • Sets status preview (canonical) or uploaded. When not canonical, it dispatches the match job and sets match_job_id.
  • updateRow:
    • skip / accept_csv change the row status only.
    • The selected_discogs_id path validates a digit string that must be one of the row's candidates_json[*].discogs_id, then DiscogsGate::allows (else discogs_unavailable), then the inline getRelease.
  • commit: compare-and-swap preview → importing. A replay when the status is done or importing → 202 with the current job. Otherwise csv_match_not_ready. On success it dispatches the import job and returns 202 {import_job_id, status, import_mode}.
  • cancel: cancels both jobs and sets status canceled.
  • serializeImport: summary comes from pluck('aggregate','status'), and an empty result encodes as [] (a PHP empty array) [ASSUMED]. progress comes from the row counts (matching, uploaded, mapping) or from the job row.

Constants and strings:

  • Statuses: 'uploaded', 'mapping', 'matching', 'preview', 'importing', 'done', 'failed', 'canceled'. Modes: 'fill_empty', 'overwrite'. Row statuses: 'pending', 'matched', 'matched_ambiguous', 'matched_csv', 'resolved', 'written', 'skipped', 'error' [VERIFIED: models/CsvImport.php:21-36, CsvImportRow.php:14-21].
  • Polish messages [VERIFIED: lang/pl/lang.php:138-147]:
    • 'not_found' => 'Nie znaleziono importu.'
    • 'csv_unreadable' => 'Nie można odczytać pliku CSV.'
    • 'csv_too_large' => 'Plik CSV jest za duży.'
    • 'csv_already_committed' => 'Ten import został już zatwierdzony.'
    • 'csv_match_not_ready' => 'Dopasowanie jeszcze się nie skończyło.'
    • 'validation_failed' => 'Niepoprawne dane importu.'
    • 'discogs_rate_limited' => 'Limit zapytań do Discogs został wyczerpany. Spróbuj za chwilę.'
    • 'discogs_unavailable' => 'Nie udało się pobrać tego wydania z Discogs.'

Parser classes to port (classes/csv/, about 1,068 lines) [VERIFIED: wc]:

Class Lines Notes
CsvAlbumParser 409 fgetcsv(..., '"', '') logical records, MAX_ROWS = 5000, BOM strip, UTF-8, then Windows-1250, then ISO-8859-2 decode, \p{L} check
CsvColumnDetector 277 Polish/English header aliases, delimiter guess, combined Artist - Title
CsvAlbumContract 213 HEADERS, exportRow, parseRow, tracklist codec, FORMULA_PATTERN = '/^[\x09\x0A\x0D ]*[=+\-@]/' formula guard
CsvPipeCodec 68 Pipe codec
CsvColumnMapper 50 Column mapping
CsvCanonicalIdMatcher 34 Canonical id matching
CsvParseException 17 Exception type

The Windows-1250 and ISO-8859-2 decoding needs golang.org/x/text/encoding/charmap. x/text is already in the tree through go-i18n (CLAUDE.md version table), but importing it directly is a new direct dependency. Name it at the plan-count checkpoint, or hand-port the two 128-entry tables (Assumption A5).

Export [VERIFIED: CsvExportApiController.php]:

  • Validates the filters, then authenticatedDatabaseQuery, which is the SQL path only.
  • Writes the BOM "\xEF\xBB\xBF", the HEADERS row, then rows streamed with lazy(100).
  • Content-Type: text/csv; charset=UTF-8.
  • The filename is plytarium-kolekcja-<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 422 validation_failed at the candidate allow-list. The seam is only reachable with rows that came from a PHP database.
  • The successful-pick recorded case stays pending for Phase 14. The skip, accept_csv, validation_failed and discogs_unavailable (gate off) cases are recorded now.

Jobs (kinds, args, queues, labels)

Go's established pattern [VERIFIED: classes/invitation_service.go:22-61, jobs.go:30-36]: InvitationMailKind = "golem15.fonoteka.invitation_mail", InvitationMailQueue = "mail", InvitationMailAttempts = 3, args {"invitation_id","token"}. It is registered with conga.Job(p.sendInvitationMail, conga.OnQueue(...), conga.MaxAttempts(...)) and inserted with Enqueue, which writes no summer_jobs row.

Job PHP Recommended Go kind Args (JSON) Insert Queue Label / delay / count / metadata Worker
CSV import new AlbumCsvImportJob((int) $import->id) via JobManager::dispatch golem15.fonoteka.csv_import {"csv_import_id":N} conga.Dispatch on the commit tx fonoteka.csv.import (D-03; must stay unserved, Finding 2) fonoteka.csv.import / 0 / row_count / {"csv_import_id":N} Phase 14
CSV match new AlbumCsvMatchJob((int) $import->id) golem15.fonoteka.csv_match {"csv_import_id":N} conga.Dispatch on the mapping tx fonoteka.csv.match (unserved) fonoteka.csv.match / 0 / row_count / {"csv_import_id":N} Phase 14 (240 s timeout; re-dispatch with the same label and extra metadata resumed_after_rate_limit, retry_after [VERIFIED: AlbumCsvMatchJob.php:189-201])
Wishlist digest new WishlistDigestJob($userId, $collectionId), 'wishlist_digest', [], 1800 golem15.fonoteka.wishlist_digest {"subscriber_id":U,"wishlist_collection_id":C} (PHP ctor names) conga.Dispatch on the item-add tx fonoteka.wishlist.digest (unserved until Phase 14) wishlist_digest / Delay: 1800*time.Second / 0 / nil (stores "") Phase 14
Purchase mail inline Mail::send (D-07 changes this) golem15.fonoteka.wishlist_purchased_mail {"subscriber_id":U,"album_name":..., "wishlist_name":...}; no secrets, names snapshotted as the inline send saw them conga.Enqueue on the purchase tx (no summer_jobs row, like invitation mail) mail — Phase 13 (registered in Jobs())

Kind names follow the existing golem15.fonoteka.<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.php with PARITY_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.yaml records one fixture.
  • seedFonotekaCase(ctx, db, store, extra) mirrors each state in Go [VERIFIED: parity/README.md "Phase 12 collections/household/albums recording"; fonoteka_seed_test.go:60-115].

New states needed in both fonoteka_reset.php and seedFonotekaCase:

  • wishlist: alice's wishlist with items, share enabled, reservations_allowed, bob subscribed, bob holding a reservation, notifications rows.
  • csv: imports in each status, with rows and a job row.
  • credentials: user and org credentials.
  • empty: zero users, for onboarding. Use isolatedFlowDB [fonoteka_flows_test.go:39].
  • invite-for-register: a pending invitation for a new email.

Captured share tokens are share:wishlist and share:collection. Add share:wishlist to capture-rules.yaml for PUT/POST wishlist/share*. Today only the collection share routes carry a capture rule [VERIFIED: capture-rules.yaml:108-121]; the manifest carries as: share:wishlist per case (manifest.yaml:586-590).

Flows: add TestFonotekaNuxtFlows/nuxt-wishlist and /nuxt-csv, a public-anonymous flow, an onboarding flow and the MCP mcp-wishlist flow, each on its own database like nuxt-albums [fonoteka_flows_test.go:21-60]. nuxt-csv uploads through tide multipart parts (pinned by sha256).

Publications: record notification:new / notification:count with parity:broadcasts. This needs the normalizer extension from Finding 8.

Throttle state across cases:

  • Laravel's inline throttle:10,1 key for guests is domain|ip and is shared by every inline-throttled route. surf mirrors that with "inline:domainless|" + ClientIP(...) [VERIFIED: surf/limiter.go:168-178].
  • So onboarding, invitation inspection and the public resolves draw from one 10/min budget per IP.
  • In Go a fresh target is built per route [parity_test.go:225-230], but cases within a route share the limiter. Keep at most 10 anonymous inline-throttled cases per route, and record the anti-enumeration sequence as its own flow.
  • On the PHP side CACHE_DRIVER=file persists limiter state, so run php_parity.sh reset (which runs cache:clear) before each anonymous recording.

The pubfail sequence: record 10 × GET public/<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} (plus g.Where("id","[0-9]+"));
  • mirror all three for public-wishlist.

Route middleware composes inside the group middleware [VERIFIED: surf/router.go:330-339, 369-412], so PublicShareHeaders still rewrites the limiter's 429 into JSON.

Fill the onboarding group (throttle:10,1, routes.go:208) and the public_invitation group (routes.go:210) as they are.

The pubfail counter: surf's Store has only Attempt (hit-and-check) [VERIFIED: surf/limiter_store.go:11-13]. PHP needs a check that does not count (RateLimiter::tooManyAttempts(key, 10)) and a separate count on failure (RateLimiter::hit(key, 60)) [VERIFIED: PublicShareApiController.php:361-397]. Add a small app-level counter keyed pubfail:<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
classes/
├── wishlist_resolver.go        # ActiveWishlist + provisioner, wishlistsVisibleTo, reservable ids
├── wishlist_subscriptions.go   # subscribe/unsubscribe/subscribersOf/followerCount/stateFor/subscriptionsFor
├── reservations.go             # reserve/cancel/reveal/releaseForAlbum/stateForViewer
├── wishlist_notifications.go   # item-added (+digest upsert/dispatch), purchased, revealed
├── similarity_finder.go
├── public_share.go             # ResolvePublic, facets, pubfail counter
├── csv/                        # parser, detector, contract, pipe codec, mapper, matcher, fputcsv writer
├── csv_import_service.go       # store/map/row/commit/cancel + job dispatch, ReleaseFetcher seam (D-05)
├── onboarding.go               # bootstrap (advisory lock) + register listener (D-10)
└── jobs_phase13.go             # args types + kind/queue/label constants
controllers/api/
├── notifications_controller.go  credentials_controller.go  onboarding_controller.go
├── wishlist_albums_controller.go wishlist_share_settings_controller.go
├── wishlist_subscriptions_controller.go wishlist_reservations_controller.go wishlist_peer_controller.go
├── csv_import_controller.go  csv_export_controller.go  public_share_controller.go
views/mail/wishlist_item_purchased.htm, wishlist_item_purchased-en.htm

Pattern 1: Constraint-disjoint overlapping routes (framework)

What: one ServeMux pattern per overlapping family, dispatched in registration order with literal and regex checks. When: only when mux.Handle would panic. Non-overlapping routes keep their own patterns.

Pattern 2: Side effects in the write transaction

What: rows, lighthouse.Emit and conga.Enqueue/Dispatch all run on the same tx. Publications and mails leave only after commit. This is the P11 D-06 and P12 D-13 pattern, already in use: WriteNotification emits on db [notification_service.go:122-142], and invitation mail is enqueued on tx [invitation_service.go:141-157].

Pattern 3: An optional reservation key in AlbumDTO

What: SerializeAlbum gains an optional viewer context. With no context there is no reservation key, which keeps every existing caller byte-identical [PHP SerializesFonoteka.php:55-112].

Anti-Patterns to Avoid

  • Using encoding/csv.Writer for the export: wrong quoting (Finding 6).
  • Putting a CSV or digest job on default or mail before its worker exists: the job is consumed and discarded (Finding 2).
  • Read-then-insert for the digest queue: use ON CONFLICT ... RETURNING (xmax = 0).
  • Mounting reserve, purchase, share, settings or peer routes on the token group: PHP excludes them (routes.php:501-506).
  • Returning credential secrets: responses carry provider, model and base_url only. The models are already json:"-".
  • Storing CSV uploads under storage/app/uploads/public: that path is web-served.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Overlapping route dispatch A per-handler path parser A surf framework feature (Finding 1) One place, and the route table stays truthful
Job rows and cancellation Custom job tables conga.Dispatch, conga.CancelJob, summer_jobs Same columns and statuses as Apparatus
Encryption at rest Custom AES lagoon.Encrypted Already decided, redacting marshal
Constant-time token compare == subtle.ConstantTimeCompare C-06
The bootstrap race An in-memory mutex pg_advisory_xact_lock + an in-tx count Survives multiple processes
Digest coalescing SELECT then INSERT ON CONFLICT DO UPDATE ... RETURNING (xmax = 0) Atomic first-in-window detection
Winter error pages Inline HTML writeWinterHTTPError (404/409/410/500 pages exist) Byte-exact recorded pages
Laravel validation Ad-hoc checks lagoon.ValidateRequest (+ prohibited) Messages and order are part of the contract
Mail Direct SMTP conga job + postcard D-07, test memory driver

Key insight: almost every primitive already exists. This phase's risk lies in the edges: route overlap, unregistered job kinds, recording-environment drift and byte details.

Runtime State Inventory

Not a rename or refactor phase. Omitted, except for one runtime note: river_job rows of the new kinds will wait in the live database until Phase 14 ships workers. That is intended (D-04). The kind and args contract makes these rows the costly-to-reverse state named in D-03.

Common Pitfalls

Pitfall 1: ServeMux conflicts only surface at boot

What goes wrong: a test that never assembles the full router passes, while serve fails. How to avoid: the route-table test assembles the real Routes() and asserts no error, then dispatches all four overlap pairs with numeric and literal segments.

Pitfall 2: Tests use the insert-only River client

What goes wrong: with no worker running, River does no unknown-kind check, so every test passes and production 500s (Finding 2). How to avoid: a conga test that starts a worker and then Dispatches an unregistered kind. An app smoke test that does the same for commit, mapping and item-add.

Pitfall 3: summary empty array and column_map key order

pluck('aggregate','status')->all() on no rows is []; with rows it is an object. Go must emit [] for none (C-03) [ASSUMED serialization]. tide's diff ignores key order, but a map-typed field re-encodes alphabetically. Keep column_map raw if Nuxt depends on its order.

Pitfall 4: CSV parse error messages are English

$this->error($e->errorCode, 422, $e->getMessage()) returns the exception's English text. A missing file uses the translated Polish text. Record both kinds.

Pitfall 5: Validator::make vs $request->validate

The former gives a 422 JSON body, the latter a 500 page (Finding 7). Choose per controller exactly as PHP does.

Pitfall 6: The public Cache-Control string order

no-store, private, exactly (Finding 6).

Pitfall 7: Postgres LIKE is case-sensitive

AlbumSimilarityFinder uses LIKE on SQLite (recording) and MariaDB _ci (production). Use ILIKE with \, %, _ escaped.

Pitfall 8: Who gets which notification

  • Item-added excludes the actor. Purchased does not exclude the actor, and also writes to the reserver when the reserver is not already a subscriber.
  • Revealed is bell-only.
  • Synthetic household peers count as subscribers with both channels enabled [VERIFIED: NotificationService.php:101-197; WishlistSubscriptionService::subscribersOf].

Pitfall 9: The throttle budget is shared across anonymous routes

The inline guest key is shared (see Parity Harness Notes). The recording and replay case budget is at most 10 per minute per IP.

Pitfall 10: The RegisterEvent payload would carry the plaintext password

fields["password_confirmation"] is still plaintext when the event fires [api_controller.go:623-671]. Strip it, and strip password, from Payload (A3).

Pitfall 11: Purchase is not transactional in PHP

PHP saves, releases and notifies without a transaction. Go wraps the whole move in one transaction. The bodies are identical, and the stronger atomicity is invisible to clients.

Pitfall 12: Users with soft deletes

User::count() in status and bootstrap excludes soft-deleted users. Count WHERE deleted_at IS NULL.

Code Examples

Digest upsert with first-in-window detection

// 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.

  1. Approve the framework changes (Findings 1 and 2) for plan 13-01?
    • Known: both are blocking in production. surf and conga are framework modules with READMEs and docs.
    • Unclear: the user's preference for the conga fix style (route unknown kinds to the insert-only client, or SkipUnknownJobCheck).
    • Recommendation: route unknown kinds through the insert-only client, keep the check for registered kinds, and document it.
    • RESOLVED: the user approved the surf overlap dispatch and the conga unregistered-kind insert onto queues nothing serves until Phase 14 (plan 13-01).
  2. Confirm the job naming contract (the jobs table: labels per PHP, D-03's queues, the kinds, the digest queue).
    • Recommendation: confirm at the plan-count checkpoint, since D-03 calls the choice costly.
    • RESOLVED: labels fonoteka.csv.import, fonoteka.csv.match and wishlist_digest kept verbatim; the contract is pinned in classes/job_contract.go (plan 13-01), rated costly and signed off.
  3. D-13's "diff recorded wishlist_digest_queue rows": is the Go-test assertion enough, or build a SQLite row dump?
    • Recommendation: notifications through API flow steps, digest rows and summer_jobs rows in Go tests (Finding 8).
    • RESOLVED: real row goldens via a php_parity.sh rows dump (digest-queue rows in 13-03, job rows in 13-04), plus Go tests.
  4. The RegisterEvent.Payload contents (A3).
    • Recommendation: a copy of the input without password and password_confirmation.
    • RESOLVED: Payload strips password and password_confirmation (user decision).
  5. x/text for CSV decoding (A5).
    • Recommendation: approve the direct import. It is already in the module graph.
    • RESOLVED: the direct golang.org/x/text import is approved.

Environment Availability

Dependency Required By Available Version Fallback
Go toolchain everything ✓ go1.27.0 —
Docker (testcontainers) DB tests, parity replay ✓ 29.7.2 —
PHP CLI (isolated parity instance) Recordings (D-12) ✓ 8.5.10 —
SQLite CLI Optional row dumps (Finding 8) ✓ 3.53.4 tinker
Node capture_clients.mjs (Nuxt/MCP capture) ✓ v22.23.2 Hand-scripted parity:record --spec flows
Centrifugo — not needed — tide fake recorder (127.0.0.1:8424) / memory driver
Discogs / AI providers — not needed (/test routes pending) — —

Missing dependencies with no fallback: none.

Validation Architecture

Test Framework

Property Value
Framework Go testing (+ testify, Go fuzzing), testcontainers Postgres
Config file none. parity/parity_test.go TestMain starts Postgres.
Quick run command cd fonoteka.go && go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short -count=1
Full suite command cd fonoteka.go && go vet ./... && go test ./... -count=1, plus cd summercms.go && go vet ./... && go test ./... -count=1
Parity command `cd fonoteka.go && go test ./parity -run 'TestParityCorpus
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 vet in the touched repo.
  • Per wave merge: the full suite in both repos plus the parity command and the corpus check.
  • Phase gate: scripts/check-phase13.sh --all green before /gsd-verify-work.

Wave 0 Gaps

  • surf constraint-aware overlap dispatch, plus a test (blocks every wishlist route).
  • conga unregistered-kind insert, plus a test.
  • lagoon prohibited rule; tide Content-Disposition date and notification publication masks.
  • php_parity.sh QUEUE_CONNECTION override; capture-rules.yaml share:wishlist capture.
  • fonoteka_reset.php + seedFonotekaCase states: wishlist, csv, credentials, empty, invite-for-register.
  • scripts/check-phase13.sh (copy of the check-phase12 structure).

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes Bootstrap creates the first owner once (advisory lock, in-tx count, 409). Register uses the shared user-plugin path (hashing, JWT).
V3 Session Management no Stateless bearer, unchanged
V4 Access Control yes Own wishlist only for write, share and settings. Peer views and reservations only via reservableWishlistIds. Subscribe-by-collection only for wishlistsVisibleTo. CSV imports scoped by user_id AND AccessibleBy(collection). Org credentials writable by owner/admin/site-admin only. Token-group isolation. 404-never-403 on ids.
V5 Input Validation yes lagoon.ValidateRequest (+ prohibited), fill allow-lists (CredentialFillFields), the request-DTO fuzz, CSV size, row and encoding limits
V6 Cryptography yes lagoon.Encrypted (AES-256-GCM) for credentials, crypto/rand share tokens, sha256 invitation hashes, subtle.ConstantTimeCompare
V7 Error/Logging yes Winter production pages, no secrets or tokens in logs or job args (purchase-mail args carry names only), no register payload secrets (A3)
V8 Data Protection yes Private CSV bucket; credentials never serialized (json:"-", responses secret-free)
V11 Business Logic yes Reserve race (row lock + unique), commit CAS, digest coalescing, throttles (share regenerate and CSV store 10/min), anti-enumeration
V12 Files yes CSV upload: extension and 5 MiB checks, private storage, formula guard on export
V13 API yes One inv.scope per token route; reserve, purchase, share, settings and peer routes absent from the token group

Known Threat Patterns (candidate T-13-xx, each with a failing-when-broken test)

ID Pattern STRIDE Standard Mitigation
T-13-01 Public token guessing / enumeration Information disclosure 16-character shape check, constant-time compare, pubfail 10/60 s per IP (peek before, hit on failure), the inline 10/min on resolve, per-token 60 and per-IP 120 on albums
T-13-02 Public view leaking non-public data (ratings, prices, notes, shelf, reservations) Information disclosure serializePublicAlbum field set; rating param → 422; facets drop zero-count rows; no reservations on public-wishlist
T-13-03 Disabled or regenerated share still resolving Spoofing public_enabled + exact token; regenerate rotates under FOR UPDATE
T-13-04 Reserving on one's own wishlist or revealing someone else's reservation Elevation of privilege 422 own; reveal only via the caller's own wishlist; cancel only by the reserver (404 otherwise)
T-13-05 The wishlist owner learning the gift giver before reveal Information disclosure stateForViewer mask (reserved_by omitted for owner and not revealed)
T-13-06 IDOR on peer wishlists, subscriptions and wishlist albums Information disclosure / Tampering reservableWishlistIds / wishlistsVisibleTo / own-wishlist scoping; Winter 404 pages identical for foreign and missing
T-13-07 Personal token reaching reserve, purchase, share, settings or peer routes Elevation of privilege Absent from the token group (route-table test)
T-13-08 Credential secret disclosure (responses, logs, JSON marshal) Information disclosure lagoon.Encrypted redacting marshal, json:"-", show returns provider, model, base_url only
T-13-09 Org credential set by a plain member; Discogs shared without rights Elevation of privilege CanManageOrg → 403 Forbidden / shared_forbidden
T-13-10 Credential owner FK re-pointed by mass assignment Tampering CredentialFillFields + fuzz
T-13-11 base_url used as SSRF Tampering Stored only in Phase 13 (`nullable
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

Six plans, sequential. Waves 1 → 6: every feature plan edits routes.go, parity/manifest.yaml and the seeds. Each plan starts with a tracer: one route recorded, ported, flipped to ported and passing before the rest.

  1. 13-01 Framework gaps and planning docs (summercms.go; fonoteka.go parity scaffolding).
    • surf overlap dispatch, the conga unregistered-kind insert, lagoon prohibited, the tide Content-Disposition date and notification publication masks, each with README and docs updates.
    • php_parity.sh QUEUE_CONNECTION override and the capture-rules share:wishlist capture.
    • ROADMAP and REQUIREMENTS rewording per D-01, D-02 and D-06, plus Phase 14 gaining wishlist match/apply-release and the credential /test routes.
    • The D-15 manifest fix.
    • Tracer: a surf test dispatching the four overlap pairs.
  2. 13-02 Notifications, credentials, onboarding, invitation inspection and the register hook (14 routes; fonoteka.go + sm-user-plugin).
    • Tracer: GET notifications.
    • The additive user-plugin exports (D-09, D-11) committed in the submodule, then the pointer bump.
    • The fonoteka listener (D-10), bootstrap with the advisory lock, inspection, the credentials CRUD with Winter 500 pages.
    • Recordings: route cases plus the onboarding flow.
  3. 13-03 Wishlist (30 routes).
    • Tracer: GET wishlist/albums on both groups.
    • The resolver and provisioner, visibility scopes, the subscription and reservation services, the AlbumDTO.reservation key, the similarity finder.
    • Share, settings and household; purchase with the purchase-mail job (worker and templates); the item-added callback branch with digest upsert and Dispatch.
    • Recordings: nuxt-wishlist (database queue), mcp-wishlist, the notification publication goldens.
  4. 13-04 CSV import and export (8 routes).
    • Tracer: GET import/csv/{id} 404 then POST import/csv.
    • The parser package port with PHP truth tables, the private bucket, the 6 import routes with CAS, Dispatch/CancelJob and the D-05 seam.
    • The export with the fputcsv port on both groups.
    • Recordings: nuxt-csv (database queue) plus route cases.
  5. 13-05 Public views and D-14 (6 routes).
    • Tracer: GET public/{token}.
    • ResolvePublic, the public search mode (TEXT_FIELDS_PUBLIC, collection-only scope), facets, serializePublicAlbum.
    • The no-store, private header fix, the per-route buckets, the pubfail counter.
    • Recordings: the public-anonymous flow.
  6. 13-06 Unit tests, security and the gate.
    • TestRouteTablePhase13, the fuzz extended to every Phase 13 write endpoint, T-13-01..23 tests, the CSV truth tables, coverage ≥ 80% per package.
    • scripts/check-phase13.sh with --removal mutations, 13-VALIDATION.md sign-off, and the security-review agent (public tokens, credentials, authorization).

Alternative (5 plans): merge 13-05 into 13-03, since public-wishlist depends on the wishlist anyway. This is not recommended: 13-03 is already the largest plan at 30 routes, and the public surface deserves its own security-focused review slice, as the user wanted in Phase 12 ("ported and security-reviewed in one place").

Ported-count progression: 99 → 113 (13-02) → 143 (13-03) → 151 (13-04) → 157 (13-05).

Sources

Primary (HIGH confidence, read this session)

  • /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (all 567 lines), Plugin.php:60-205
  • PHP controllers: NotificationApiController, Wishlist{Album,AlbumReservation,Household,PeerAlbum,Settings,Share,Subscription}Controller, CsvImportApiController, CsvExportApiController, AiCredentialController, OrgAiCredentialController, DiscogsCredentialController, OnboardingController, InvitationApiController, PublicShareApiController; middleware PublicShareHeaders; traits SerializesFonoteka, SerializesPublicAlbum
  • PHP services and models: NotificationService, WishlistSubscriptionService, AlbumReservationService, ActiveWishlistResolver, WishlistProvisioner, CollectionShareService, AlbumSimilarityFinder, AlbumWriteService::moveToCollection, AiGate, AiConfigResolver, DiscogsConfigResolver, DiscogsGate, OrgAccess, models/WishlistDigestQueue, CsvImport, CsvImportRow, Collection scopes, UserAiCredential; jobs/AlbumCsvImportJob, AlbumCsvMatchJob, WishlistDigestJob; apparatus JobManager and JobStatus; websockets CentrifugoClient and BroadcastableModel; user ApiController@register and AuthManager; lang/pl csv_import; views/mail wishlist_item_purchased*
  • Laravel 9.x vendor: Validator implicit rules, validateProhibited
  • River v0.47.0: client.go:2270-2276, internal/jobexecutor/job_executor.go:219
  • Go app: routes.go, plugin.go, jobs.go, mail.go, classes/{notification_service,invitation_service,gates,access,credential_write_service,org_provisioner,share_service,album_search,serialize_album}.go, models/, middleware/public_share_headers.go, controllers/api/http_errors.go, updates/.go (constraints)
  • sm-user-plugin: classes/events.go, controllers/api_controller.go (Register, apiArray, mintFor), README
  • Framework: surf/router.go, surf/limiter.go, limiter_store.go, bodylimit.go; conga/conga.go, client.go, worker.go, record.go; lagoon/validate_rules.go, encrypted.go; tide/diff.go, centrifugo_golden.go
  • Parity: manifest.yaml (scripted comparison), fixtures/routes/* in scope, parity/README.md, php_parity.sh, capture-rules.yaml, parity_test.go, fonoteka_seed_test.go, fonoteka_flows_test.go, db_capture.go
  • Probes run this session: the ServeMux conflict probe (Go 1.27), the PHP 8.5.10 fputcsv vs Go encoding/csv comparison, tool versions

Secondary (MEDIUM confidence)

  • Phase 12 CONTEXT, RESEARCH, VALIDATION and SUMMARYs (patterns, verify commands, gate script structure); 11-CONTEXT D-10

Tertiary (LOW confidence)

  • The assumptions A1, A2, A4, A6 and A7 above (unprobed framework behaviours)

Metadata

Confidence breakdown:

  • Route inventory and the PHP contract: HIGH. Every route, controller and constraint was read; the manifest and fixtures were compared by script.
  • Framework gaps (Findings 1, 2, 6, 9): HIGH. Proved by a probe or by reading the source.
  • Recording strategy (Findings 4, 5, 8): HIGH on the problems, MEDIUM on the exact recipes.
  • Plan split and sizing: MEDIUM.

Research date: 2026-10-02 Valid until: 2026-11-01 (stable codebase; re-check if Phase 12.2 changes surf or conga)