Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-PLAN.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

48 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 01 execute 1
modules/surf/router.go
modules/surf/overlap.go
modules/surf/overlap_test.go
modules/surf/README.md
docs/services/routing.md
modules/conga/conga.go
modules/conga/unregistered_kind_test.go
modules/conga/README.md
docs/services/jobs.md
modules/lagoon/validate_rules.go
modules/lagoon/validate_request.go
modules/lagoon/validate_request_test.go
modules/lagoon/README.md
docs/database/casts-and-validation.md
modules/tide/diff.go
modules/tide/centrifugo_golden.go
modules/tide/normalize_phase13_test.go
modules/tide/README.md
docs/services/parity-testing.md
../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go
../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go
../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go
../fonoteka.go/parity/php_parity.sh
../fonoteka.go/parity/capture-rules.yaml
../fonoteka.go/parity/check_corpus.go
../fonoteka.go/parity/check_corpus_test.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/README.md
.planning/ROADMAP.md
.planning/REQUIREMENTS.md
true
API-03
API-04
API-05
API-06
API-07
tokens raw_tokens tasks confidence
240000 240000 4 low
truths artifacts key_links prohibitions
Per RESEARCH Finding 1 (user-approved framework change), a surf router holding two same-method routes that ServeMux rejects as conflicting (for example `GET /shelves/items/{id}` with `id [0-9]+` and `GET /shelves/{shelfId}/items` with `shelfId [0-9]+`) boots without `surf: route conflict` and sends each request to the first route in registration order whose literal segments and Where constraints match, with that route's own path values, middleware, body limit and recovery.
A request whose path only the generated family pattern matches (no member's literals and constraints match) answers the same bare 404 the router gives any unknown path, for every method; a method mismatch on a path some member or other route matches answers the 405 and Allow header ServeMux gives for the same table without the conflict.
`(*surf.Router).Routes()` and `summer route:list` still list every member of an overlap family as its own route with its own pattern and middleware; routes outside a family register on ServeMux exactly as before.
The four routes.php wishlist pairs (`GET wishlist/token/{token}/subscribe` vs `GET wishlist/{collectionId}/albums/{albumId}`, `GET wishlist/{collectionId}/subscribe` vs `GET wishlist/albums/{id}`, `DELETE wishlist/{collectionId}/subscribe` vs `DELETE wishlist/albums/{id}`, `GET wishlist/albums/{id}` vs `GET wishlist/{collectionId}/albums`) assemble on one surf router in fonoteka.go and dispatch numeric and literal segments to the PHP-equivalent route.
Per RESEARCH Finding 2 (user-approved framework change), while an in-process conga worker runs, `Manager.Dispatch` and `Manager.Enqueue` of a job kind that no plugin registered succeed (they insert through the insert-only River client) and the job stays `available` (or `scheduled` with a delay) because its queue is not served; a registered kind still inserts exactly as before.
conga refuses, with `conga.ErrUnregisteredKindQueue`, to insert an unregistered kind with an empty queue or onto a queue the worker set serves (default, `scheduled`, every configured queue and every registered job's queue), so a workerless job can never be fetched and discarded.
Per D-03 and D-08 (costly, signed off 2026-10-02) the job contract lives in one file, `classes/job_contract.go`, and `TestJobContract` pins every string: kinds `golem15.fonoteka.csv_import`, `golem15.fonoteka.csv_match`, `golem15.fonoteka.wishlist_digest`, `golem15.fonoteka.wishlist_purchased_mail`; queues `fonoteka.csv.import`, `fonoteka.csv.match`, `fonoteka.wishlist.digest`, `mail`; labels `fonoteka.csv.import`, `fonoteka.csv.match`, `wishlist_digest`; digest delay 1800 s; args JSON `{"csv_import_id":N}` and `{"subscriber_id":U,"wishlist_collection_id":C}` and `{"subscriber_id":U,"album_name":S,"wishlist_name":S}`.
With the fonoteka plugins' jobs registered and a worker running, dispatching `CsvImportArgs` on `fonoteka.csv.import` with label `fonoteka.csv.import` succeeds, writes the summer_jobs row and leaves the River job unworked (D-04: no worker, no fake success).
`lagoon.ValidateRequest` supports Laravel 9 `prohibited`: non-implicit, it fails only for a present, non-blank value that `required` would accept (a string, a number including 0, a boolean including false, a non-empty array), passes for an absent key, null, an empty string and an empty array, and its message is the literal `validation.prohibited` because neither Winter catalog defines the key.
tide compares `Content-Disposition` with every `YYYY-MM-DD` date masked on both sides after asserting each side's date is a real calendar date, so a later replay of `attachment; filename=export-2026-09-17.csv` passes while a different stem, a missing date or `2026-13-45` is still a Diff.
`tide.NormalizePublications` masks a Carbon `+00:00` value at `$.data.payload.created_at` as `{{datetime}}` and a positive integer at `$.data.payload.id` that no captured id variable names as `{{id}}`; a `Z` date, a non-integer id and every other path stay visible.
`parity/php_parity.sh` honours `QUEUE_CONNECTION` from the environment (default `sync`) and gains a `rows` subcommand that prints the parity SQLite result of one SELECT as JSON for row goldens (D-13); `capture-rules.yaml` captures the wishlist share token as `share:wishlist` (category share) on `GET/PUT wishlist/share` and `POST wishlist/share/regenerate`.
Per D-15 and RESEARCH Finding 5, the manifest case of `GET /_fonoteka/api/v1/invitations/{token}` expects the recorded 200, and check_corpus fails a `status: ported` route whose case status differs from its fixture's recorded status.
Per D-01, D-02 and D-06, ROADMAP Phase 13 criteria 1-4, its repos line, Phase 14 criteria 4-5, and REQUIREMENTS API-03, API-04, API-06, INTG-01 and INTG-02 say what Phase 13 ships and what moves to Phase 14, in a planning-docs-only commit.
Edge (API-03 adjacency): overlap families are computed transitively, so the three-way GET family (`wishlist/albums/{id}`, `wishlist/{collectionId}/subscribe`, `wishlist/{collectionId}/albums`) is one family and `GET wishlist/albums/similar` keeps its own more specific pattern.
Edge (API-03 boundary): `GET wishlist/albums/subscribe` and `GET wishlist/0x1/albums` reach no member (both fail the numeric constraints) and answer 404, as Laravel does.
statement verification
Edge (API-05 concurrency): a Dispatch of an unregistered kind racing a worker start never routes through the worker client; River's unknown-kind check cannot fire for it. backstop
path provides contains
modules/surf/overlap.go overlap family detection and the constraint-aware dispatcher used by compile SetPathValue
path provides contains
modules/conga/conga.go ErrUnregisteredKindQueue and insert-only routing for unregistered kinds ErrUnregisteredKindQueue
path provides contains
modules/lagoon/validate_rules.go prohibited rule "prohibited"
path provides contains
modules/tide/centrifugo_golden.go payload created_at and id masks $.data.payload.created_at
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go the Phase 13 job kinds, queues, labels and args types fonoteka.wishlist.digest
path provides contains
../fonoteka.go/parity/php_parity.sh QUEUE_CONNECTION override and rows subcommand QUEUE_CONNECTION:-sync
from to via pattern
modules/surf/router.go modules/surf/overlap.go compile registers overlap families through one dispatcher pattern instead of one mux.Handle per member overlap
from to via pattern
modules/conga/conga.go modules/conga/client.go insertClient picks the insert-only client for a kind with no registered job; knownQueues decides the served-queue refusal knownQueues
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go modules/conga/worker.go StartWorker with the app jobs, then Dispatch of CsvImportArgs StartWorker
requirement_id category statement status verification
API-05 transparency A Phase 13 job with no worker MUST NOT be worked, completed or discarded by any stub; it stays queued until Phase 14 registers its worker (D-04) resolved test
requirement_id category statement status verification
API-03 transparency The overlap dispatcher MUST NOT collapse the wishlist routes into one app-level handler or drop them from the route table; every PHP route stays its own route (C-01) resolved test
requirement_id category statement status verification
API-07 safety Framework code, tests, READMEs and docs changed here MUST NOT name the consuming application; examples use neutral names (acme, shelves, items) resolved test

Phase Goal

ROADMAP Phase 13 goal (verbatim, not in user-story form; the MVP precedent of Phases 11, 11.2 and 12 is to quote it): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets.

This plan's slice: the framework can register PHP's overlapping constrained routes, queue a job whose worker ships later without failing or losing it, validate prohibited, and compare dated download names and notification publications; the app has one pinned job contract; the parity harness can record with a real queue and dump rows; the roadmap says what Phase 13 ships. Plans 13-02 to 13-05 build every route on these pieces.

Close the framework gaps of RESEARCH Findings 1, 2, 6 (date mask), 8 (publication masks) and 9 (`prohibited`) in summercms.go, pin the D-03/D-08 job contract in fonoteka.go, add the parity scaffolding (Finding 4 queue override, the D-13 row dump, the wishlist share capture, the D-15 manifest fix and a ported-case status check), and reword the planning docs per D-01, D-02 and D-06.

Purpose: without the surf change the app panics at boot once the wishlist routes exist; without the conga change commit, mapping and wishlist item-add return 500 in production (work_in_serve: true). Decisions implemented: D-01, D-02, D-03, D-04, D-06, D-08 (contract), D-13 (row dump tooling), D-15, C-01, C-07. Output: framework features with README and docs updates, classes/job_contract.go, parity tooling, reworded ROADMAP/REQUIREMENTS.

Repos: summercms.go (framework, planning docs) and fonoteka.go (job contract, tests, parity tooling). Another session is committing Phase 12.2 work in summercms.go concurrently: stage only the paths of the task at hand (git add <paths>), never git add -A or git add ., and rebase on HEAD before committing if needed. Framework code, tests, READMEs and docs never name the application (CLAUDE.md). Planning docs and code go in separate commits; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-PATTERNS.md @modules/surf/router.go @modules/conga/conga.go - surf (today): `Router.add` appends `route{pluginID, method, path, handler, middleware, raw, constraints}`; `Router.compile` loops `r.wrap(rt)` then `handleRoute(mux, rt, h)` which calls `mux.Handle(method+" "+path, h)` and turns a ServeMux panic into `surf: route conflict for ...`; `wrap` builds `constrain(handler, constraints)` (a failed constraint answers `http.NotFound`), body limit, named and factory middleware, locale, recovery; `pathHasParam`; `Constraint{param, re, enum}` with `match`; `RouteInfo{Method, Pattern, PluginID, Middleware, Raw}` from `Routes()`; `summer route:list` in routelist_command.go. - conga (today): `Manager.Dispatch(ctx, db, args pact.JobArgs, DispatchOpts{Label, Count, Metadata, Queue, Delay, MaxAttempts}) (uint, error)` writes a summer_jobs row (status in progress, metadata `""` when nil, user from the bouncer principal) and `client.InsertTx`s on the same `*sql.Tx`; `Manager.Enqueue(ctx, db, args, EnqueueOpts{Queue, Delay, MaxAttempts})`; `insertOpts` takes queue and attempts from a registered job when the opts leave them empty (an unregistered kind with no queue lands on River's `default`); `insertClient()` returns `m.worker` whenever a worker runs (River then rejects an unregistered kind with `UnknownJobKindError`), else a lazily built insert-only client (`newInsertClient`, no Workers, no kind check); `knownQueues(settings, jobs)` = `default` + registered job queues + configured `queue.queues`; StartWorker also serves `QueueScheduled`; `CancelJob(ctx, id)` sets is_canceled and StatusStopped and cancels the River job. - lagoon: `requestRuleArity` map, `implicitRuleNames`, `ParseRules` panics on an unknown rule at init; `(*requestValidator).message` falls back to `"validation." + rule` when the catalog has no key. - tide: `compareHeaders(want, got, extra)` compares `Content-Disposition` byte for byte (extraCompareHeaders); `NormalizePublications(pubs, store)` and `(*normalizer).walk` mask `$.data.timestamp`, `$.data.payload.timestamp`, `$.data.payload.actor`, `*_at` under `$.data.payload.album.` and captured ids; `carbonOffsetRe`, `isoOffsetRe`, `isIDKey`. - fonoteka.go parity: `php_parity.sh` `export_env` sets `QUEUE_CONNECTION=sync` (line 41) and the SQLite file `$DB`; capture-rules.yaml lines 108-123 capture `share:collection`; check_corpus.go `--require-recorded --check-secrets`; manifest entry `GET /_fonoteka/api/v1/invitations/{token} public_invitation` (case status 404, fixture 200). - fonoteka app jobs: `jobs.go` `Jobs()` registers the invitation mail job on `classes.InvitationMailQueue` ("mail"); `classes.InvitationMailKind = "golem15.fonoteka.invitation_mail"`.

Artifacts this phase produces

(This plan's share.)

  • surf: constraint-aware overlap dispatch inside compile (unexported overlapFamilies, family dispatcher in modules/surf/overlap.go); no exported API change; README and docs/services/routing.md section "Overlapping constrained routes".
  • conga: ErrUnregisteredKindQueue; unregistered kinds insert through the insert-only client; README and docs/services/jobs.md section "Jobs whose worker ships later".
  • lagoon: request rule prohibited.
  • tide: Content-Disposition date masking in header comparison; $.data.payload.created_at and $.data.payload.id publication masks.
  • fonoteka classes (job contract): constants CsvImportKind, CsvImportQueue, CsvImportLabel, CsvMatchKind, CsvMatchQueue, CsvMatchLabel, WishlistDigestKind, WishlistDigestJobQueue, WishlistDigestLabel, WishlistDigestDelay, WishlistPurchasedMailKind, WishlistPurchasedMailQueue, WishlistPurchasedMailAttempts; types CsvImportArgs{CsvImportID}, CsvMatchArgs{CsvImportID}, WishlistDigestArgs{SubscriberID, WishlistCollectionID}, WishlistPurchasedMailArgs{SubscriberID, AlbumName, WishlistName}, each with Kind() string.
  • Tests: TestOverlappingConstrainedRoutes, TestUnregisteredKindWithWorker, TestValidateRequestProhibited, TestNormalizeContentDispositionDate, TestNormalizeNotificationPublication, TestWishlistOverlapPatternsDispatch, TestJobContract, TestJobContractDispatchWhileWorkerRuns, TestCheckCorpusPortedCaseStatus.
  • Parity tooling: php_parity.sh rows <sql>, QUEUE_CONNECTION override, capture share:wishlist.

Job contract (D-03, D-08) — the one place the names are stated

Phase 14 workers consume these kinds and args, and queued river_job rows in a live database carry them; renaming later needs a coordinated worker change and a row migration. The user signed this table off at the plan-count checkpoint on 2026-10-02, so it is recorded here without a new checkpoint.

Job Kind Queue summer_jobs label Args JSON Insert Worker
CSV import (PHP AlbumCsvImportJob) golem15.fonoteka.csv_import fonoteka.csv.import (unserved until Phase 14) fonoteka.csv.import {"csv_import_id":N} conga.Dispatch, Count = row_count, Metadata {"csv_import_id":N} Phase 14 (JOBS-02)
CSV match (PHP AlbumCsvMatchJob) golem15.fonoteka.csv_match fonoteka.csv.match (unserved) fonoteka.csv.match {"csv_import_id":N} conga.Dispatch, Count = row_count, Metadata {"csv_import_id":N} Phase 14 (JOBS-02)
Wishlist digest (PHP WishlistDigestJob) golem15.fonoteka.wishlist_digest fonoteka.wishlist.digest (unserved) wishlist_digest {"subscriber_id":U,"wishlist_collection_id":C} conga.Dispatch, Delay 1800 s, Count 0, Metadata nil (stores "") Phase 14 (JOBS-03)
Wishlist purchase mail (D-07) golem15.fonoteka.wishlist_purchased_mail mail none (Enqueue writes no summer_jobs row) {"subscriber_id":U,"album_name":S,"wishlist_name":S} conga.Enqueue, 3 attempts Phase 13 (13-03)

Known difference (RESEARCH Finding 3): PHP's JobManager rows get user_id = NULL on JWT requests; conga.Dispatch stores the request principal. No client reads the column; it is recorded in parity/README.md.

Assumption-delta decision

<assumption_delta_decision> Detector run on the Phase 13 ROADMAP section: detected=false. Considered by hand: public-wishlist mirrors the public collection view with kind='wishlist' (a second kind of shared collection, not a second identity); the share token stays on the collection row and the collection id stays the identity. Noun primary: Collection. Decision: no-change. Rationale: kind already models both; no anchor moves. </assumption_delta_decision>

Flagged assumptions

  • A2 (RESEARCH): the prohibited message is the literal validation.prohibited; plan 13-03 records a wishlist store with condition set to settle it.
  • The overlap dispatcher's 405 contract is defined as "what ServeMux answers for the same table without the conflict"; PHP answers 404 for paths no route matches, which the 404 truth covers.
Task 1: The app's four overlapping wishlist route pairs boot on one router and each request reaches the PHP-equivalent route Internal to surf.compile; no exported API changes and non-conflicting routes register exactly as before. modules/surf/router.go, modules/surf/overlap.go, modules/surf/overlap_test.go, modules/surf/README.md, docs/services/routing.md, ../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go modules/surf/router.go (whole file: route, add, compile, handleRoute, wrap), modules/surf/params.go (Constraint, match, constrain, pathHasParam), modules/surf/routetable.go, modules/surf/routelist_command.go, modules/surf/router_test.go, modules/surf/README.md, docs/services/routing.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 134-225 and 507-510), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Finding 1), ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (phase12RouteTable boot recipe), ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go Per RESEARCH Finding 1 (user-approved) and C-01, make surf register overlapping constrained routes with Laravel's registration-order semantics.

(1) modules/surf/overlap.go: before compile registers anything, compute overlap families over r.routes. Two routes overlap-conflict when ServeMux would panic for them: same method (a GET route also answers HEAD as ServeMux does), the same segment count, every segment pair either equal literals or containing a single-segment wildcard, and neither pattern matching a strict subset of the other's paths. Families are the transitive closure of that relation. Routes outside every family keep the current one-pattern-per-route registration through handleRoute. A family member whose pattern uses a multi-segment wildcard, the end anchor or a host fails compile with an error naming both routes (unsupported, never silently misrouted).

(2) Register each family under one generated pattern whose segments are the members' shared literal where they all agree and a generated wildcard elsewhere. The family handler walks members in registration order; the first member whose literal segments equal the request's segments and whose constraints all match gets its own named path values set with Request.SetPathValue, then runs its own fully wrapped handler (the same wrap output compile builds today, so middleware, body limit, locale and recovery stay per member). No member matching answers http.NotFound. The registration must not create a 405 ServeMux would not have answered: a request whose path only the generated pattern matches answers 404 for every method, and a method mismatch on a path a member or another route matches answers ServeMux's 405 with the same Allow header as a mux holding the same table without the conflict (one way: register the family pattern without a method and resolve method, 405 and Allow inside the family handler from the router's full route list; families of different methods that generalise to the same shape share one registration).

(3) Routes() and route:list stay one entry per route (no family entries). Add a short paragraph to the surf README (Features, and a Usage note) and a section "Overlapping constrained routes" to docs/services/routing.md in prose: when it applies, registration-order dispatch, 404/405 behaviour, unsupported shapes. Neutral examples only (shelves, items).

(4) modules/surf/overlap_test.go TestOverlappingConstrainedRoutes with neutral names mirroring the four app shapes (for example GET /shelves/token/{token}/follow vs GET /shelves/{shelfId}/items/{itemId}, GET|DELETE /shelves/{shelfId}/follow vs GET|DELETE /shelves/items/{id}, GET /shelves/items/{id} vs GET /shelves/{shelfId}/items, plus a literal GET /shelves/items/similar): subtests boot (no compile error), dispatch (numeric and literal segments reach the right handler with the right PathValue names), constraint-404 (/shelves/items/follow, /shelves/0x1/items answer 404 text/plain), method-405 (status and Allow equal a reference ServeMux built from the same table minus the conflicting partner), unknown-method-404 (POST to a path only the family pattern matches answers 404), middleware (each member's own middleware runs, the other member's does not), route-table (Routes() lists every member once), unsupported (a family with {rest...} is a compile error).

(5) fonoteka.go routes_overlap_test.go TestWishlistOverlapPatternsDispatch: build a surf router with stub handlers that record which route ran, registered with the exact routes.php wishlist patterns and constraints under /_fonoteka/api/v1 (lines 171-176, 197-206, 224-225), and assert each of the four conflicting pairs plus GET wishlist/albums/similar dispatch as PHP would. This proves the real shapes before plan 13-03 mounts the real handlers. go vet ./... && go test ./modules/surf -count=1 -v -run '^(TestOverlappingConstrainedRoutes)$' && go test ./modules/surf -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -v -run '^(TestWishlistOverlapPatternsDispatch|TestRouteTablePhase12|TestFullRouteTableAuthGroupMutualExclusivity)$' <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestOverlappingConstrainedRoutes" and "--- PASS: TestWishlistOverlapPatternsDispatch"; docs:build --check or TestDocsTree reports an unknown identifier or broken link.</fails_when> <acceptance_criteria> - grep -c 'SetPathValue' modules/surf/overlap.go prints at least 1. - grep -c 't.Run(' modules/surf/overlap_test.go prints at least 8. - grep -ci 'overlapping constrained routes' docs/services/routing.md prints at least 1 and grep -ci 'overlap' modules/surf/README.md prints at least 1. - grep -rniE 'fonoteka|plytarium|płytarium|wishlist' modules/surf/overlap.go modules/surf/overlap_test.go modules/surf/README.md docs/services/routing.md prints nothing (framework stays app-agnostic). - grep -c 'wishlist/{collectionId}/albums/{albumId}' ../fonoteka.go/plugins/golem15/fonoteka/routes_overlap_test.go prints at least 1. </acceptance_criteria> The framework registers PHP-style overlapping constrained routes and the app's exact wishlist shapes dispatch correctly, so plan 13-03 can mount the real routes without a boot panic.

Task 2: A job whose worker ships in Phase 14 can be queued from a request while the in-process worker runs, under one pinned job contract The job contract table above (D-03/D-08) is the costly decision this task writes into code; the conga change itself is reversible and additive. modules/conga/conga.go, modules/conga/unregistered_kind_test.go, modules/conga/README.md, docs/services/jobs.md, ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract_test.go, ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go modules/conga/conga.go (Dispatch, Enqueue, CancelJob, insertOpts, insertClient), modules/conga/client.go (settingsFromApp, knownQueues, newInsertClient, baseConfig), modules/conga/worker.go (StartWorker, WorkerOptions, QueueScheduled), modules/conga/manager_test.go and worker_test.go (worker start recipe, poll-only option, Postgres harness), modules/conga/README.md, docs/services/jobs.md, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go (InvitationMailKind, InvitationMailQueue, InvitationMailArgs), ../fonoteka.go/config/queue.yaml, ../fonoteka.go/plugins/golem15/fonoteka/household_smoke_test.go (river_job assertions), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvImportJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvMatchJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/WishlistDigestJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/WishlistDigestQueue.php, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 2 and 3, Jobs table) Per RESEARCH Finding 2 (user-approved), D-03, D-04 and D-08.

(1) conga.go: add exported ErrUnregisteredKindQueue. In Dispatch and Enqueue, when args.Kind() has no registered job: require a non-empty queue in the opts and refuse a queue that the known-queue set contains (knownQueues plus QueueScheduled), returning an error that wraps ErrUnregisteredKindQueue and names the kind and queue; then insert through the insert-only client (build it lazily even while a worker runs; keep it separate from m.worker). A registered kind keeps today's client choice and behaviour. CancelJob, Get and the status helpers keep working for workerless jobs (they act on summer_jobs and cancel through a client that never checks kinds).

(2) Tests modules/conga/unregistered_kind_test.go TestUnregisteredKindWithWorker on the conga Postgres harness with neutral names: register one real job (queue acme.mail), start a worker (poll-only, short poll interval as the existing worker tests do), then subtests dispatch-unregistered (Dispatch of kind acme.pending_import on queue acme.pending returns an id, the summer_jobs row has the label and river_job_id, the river_job row is available with attempt 0 and still is after three poll intervals), enqueue-delayed (Enqueue with a delay gives scheduled), refuse-served (onto default, scheduled and acme.mail errors with ErrUnregisteredKindQueue and inserts nothing), refuse-empty (empty queue errors), cancel (CancelJob stops the summer_jobs row and cancels the River job), registered-unchanged (the registered kind still runs on the worker).

(3) Docs in the same change: conga README (API reference entry for ErrUnregisteredKindQueue, a Usage note) and a docs/services/jobs.md section "Jobs whose worker ships later" in prose: dispatch onto a queue nothing serves, the refusal rules, rows wait until a worker for the kind is registered and its queue becomes served. Neutral names.

(4) fonoteka.go classes/job_contract.go: the constants and args types listed in the Job contract table and the Artifacts section, with JSON tags exactly csv_import_id, subscriber_id, wishlist_collection_id, album_name, wishlist_name, WishlistDigestDelay = 1800 * time.Second, WishlistPurchasedMailQueue = "mail", WishlistPurchasedMailAttempts = 3, and a doc comment pointing at the PHP job classes and stating that only the purchase mail kind gets a worker in Phase 13. classes/job_contract_test.go TestJobContract pins every kind, queue, label and the marshalled args JSON byte for byte, and asserts no Phase 13 queue name appears in ../fonoteka.go/config/queue.yaml (read the file).

(5) fonoteka.go job_contract_worker_test.go TestJobContractDispatchWhileWorkerRuns: boot the app plugins' jobs into a conga Manager on the plugin test database, start a worker, Dispatch classes.CsvImportArgs{CsvImportID: 1} with label CsvImportLabel, queue CsvImportQueue, Count 3 and Metadata {"csv_import_id":1}; assert success, the summer_jobs row (label, status in progress, progress_max 3, metadata JSON) and an unworked river_job row of kind CsvImportKind; then Dispatch WishlistDigestArgs with WishlistDigestDelay and assert a scheduled row. go vet ./... && go test ./modules/conga -count=1 -v -run '^(TestUnregisteredKindWithWorker)$' && go test ./modules/conga -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes -count=1 -v -run '^(TestJobContract|TestJobContractDispatchWhileWorkerRuns)$' <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestUnregisteredKindWithWorker", "--- PASS: TestJobContract" and "--- PASS: TestJobContractDispatchWhileWorkerRuns".</fails_when> <acceptance_criteria> - go doc ./modules/conga ErrUnregisteredKindQueue exits 0 and grep -c 'ErrUnregisteredKindQueue' modules/conga/README.md prints at least 1. - grep -ci 'worker ships later' docs/services/jobs.md prints at least 1. - grep -c '"fonoteka.wishlist.digest"' ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go prints 1 and grep -c 'golem15.fonoteka.csv_match' ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go prints 1. - grep -cE 'fonoteka\.(csv|wishlist)\.' ../fonoteka.go/config/queue.yaml prints 0. - grep -rniE 'fonoteka|plytarium|wishlist' modules/conga/conga.go modules/conga/unregistered_kind_test.go modules/conga/README.md docs/services/jobs.md prints nothing. </acceptance_criteria> A request can queue a Phase 14 job in its write transaction while the server's worker runs, the job waits unworked, and every name Phase 14 will consume is pinned by a test.

Task 3: A wishlist item request with a forbidden field gets Laravel's prohibited error, and dated CSV downloads and notification publications replay on later days modules/lagoon/validate_rules.go, modules/lagoon/validate_request.go, modules/lagoon/validate_request_test.go, modules/lagoon/README.md, docs/database/casts-and-validation.md, modules/tide/diff.go, modules/tide/centrifugo_golden.go, modules/tide/normalize_phase13_test.go, modules/tide/README.md, docs/services/parity-testing.md modules/lagoon/validate_rules.go (requestRuleArity, implicitRuleNames), modules/lagoon/validate_request.go (rule dispatch, message, presence and blank handling), modules/lagoon/validate_request_test.go, modules/lagoon/README.md, docs/database/casts-and-validation.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php (validateProhibited, validateRequired), /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php (implicitRules, presentOrRuleIsImplicit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistAlbumApiController.php (lines 280-310 rules), modules/tide/diff.go (compareHeaders), modules/tide/centrifugo_golden.go (NormalizePublications, normalizer.walk), modules/tide/normalize.go (maskDate, carbonOffsetRe), modules/tide/README.md, docs/services/parity-testing.md, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml (Content-Disposition), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (lines 265-284, publication payload) (1) lagoon, per RESEARCH Finding 9: add `prohibited` (arity 0, not implicit) to the request validator. It runs only when the attribute is validatable (present and not a blank string, as for any non-implicit rule) and fails when Laravel's validateRequired would pass for the value: a non-empty string, any number (0 included), any boolean (false included), a non-empty array or map. null and an empty array pass. Its message goes through the existing lookup and therefore stays the literal `validation.prohibited` (no catalog key exists in Winter's pl or en files; do not add one). `TestValidateRequestProhibited` in validate_request_test.go covers absent, null, empty string, empty array, "VG", 0, false, a non-empty array, and the message text in pl and en. README rule list and the casts-and-validation docs page list `prohibited` in prose.

(2) tide, per RESEARCH Finding 6 point 2: in header comparison, compare Content-Disposition after replacing every YYYY-MM-DD token on both sides with one placeholder, but only after checking each replaced token parses as a real calendar date on its side; a token that does not parse, or a date present on one side only, keeps the byte comparison so the Diff stays visible. No application name or filename stem is hard-coded.

(3) tide, per RESEARCH Finding 8 (masking only): in normalizer.walk, mask $.data.payload.created_at holding a Carbon +00:00 value as {{datetime}} with the same shape check used for album dates, and mask $.data.payload.id holding a positive integer as {{id}} only when no captured id variable names it (a captured value keeps its {{id:name}} placeholder). Leave every other path unchanged.

(4) Tests modules/tide/normalize_phase13_test.go: TestNormalizeContentDispositionDate (same stem on different days: no Diff; different stem: Diff; 2026-13-45: Diff; date only on one side: Diff; header absent on got: Diff) and TestNormalizeNotificationPublication (a notification:new body with id and created_at normalizes equal across two runs with different ids and times; a Z created_at and a string id stay visible; an album publication's existing masks are unchanged). README and docs/services/parity-testing.md describe both masks in prose with neutral examples. go vet ./... && go test ./modules/lagoon ./modules/tide -count=1 -v -run '^(TestValidateRequestProhibited|TestNormalizeContentDispositionDate|TestNormalizeNotificationPublication)$' && go test ./modules/lagoon ./modules/tide -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go test ./parity -count=1 -run '^(TestUserAPINuxtFlows|TestBroadcastGoldens)$' <fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS" line for each of the three named tests; TestBroadcastGoldens or TestUserAPINuxtFlows fails (an existing golden or recorded body regressed).</fails_when> <acceptance_criteria> - grep -c '"prohibited"' modules/lagoon/validate_rules.go prints at least 1 and grep -c 'prohibited' modules/phrasebook/lang/pl/validation.yaml prints 0. - grep -c 'prohibited' modules/lagoon/README.md and grep -c 'prohibited' docs/database/casts-and-validation.md each print at least 1. - grep -c 'payload.created_at' modules/tide/centrifugo_golden.go prints at least 1. - grep -ci 'content-disposition' modules/tide/README.md and grep -ci 'content-disposition' docs/services/parity-testing.md each print at least 1. - grep -rniE 'fonoteka|plytarium' modules/tide/diff.go modules/tide/centrifugo_golden.go modules/tide/normalize_phase13_test.go modules/lagoon/validate_rules.go prints nothing. </acceptance_criteria> The validator speaks Laravel's prohibited rule, and the parity diff can compare a dated download and a notification publication recorded on another day without hiding a real difference.

Task 4: Recordings can run with a real queue and dump rows, ported cases cannot drift from their fixtures, and the roadmap says what Phase 13 ships `bash -n ../fonoteka.go/parity/php_parity.sh` exits 0 before the edit and `command -v sqlite3` succeeds (the rows subcommand shells out to it). ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/README.md, .planning/ROADMAP.md, .planning/REQUIREMENTS.md ../fonoteka.go/parity/php_parity.sh (whole file: export_env, refuse_db, subcommands), ../fonoteka.go/parity/capture-rules.yaml (lines 100-125), ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/manifest.yaml (the `GET /_fonoteka/api/v1/invitations/{token} public_invitation` entry), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_invitations_{token}_public_invitation.yaml, ../fonoteka.go/parity/README.md (Phase 12 recording sections), .planning/ROADMAP.md (Phase 13 and 14 sections), .planning/REQUIREMENTS.md (API-03, API-04, API-06, INTG-01, INTG-02), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md (D-01, D-02, D-06, D-13, D-15), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Findings 4, 5 and 8) (1) php_parity.sh, per RESEARCH Finding 4: `export_env` exports `QUEUE_CONNECTION="${QUEUE_CONNECTION:-sync}"` so `QUEUE_CONNECTION=database php_parity.sh serve` keeps jobs queued (Winter's jobs migrations already create the table on reset). Add a `rows` subcommand, per D-13: it takes one read-only SELECT (refuse anything that does not start with SELECT after trimming, and refuse a semicolon), runs it against the parity SQLite with the existing refuse_db guard, and prints a JSON array of row objects to stdout (sqlite3 JSON mode). It never writes under parity/fixtures by itself.

(2) capture-rules.yaml: copy the collection share rule for the wishlist share surface: GET and PUT /_fonoteka/api/v1/wishlist/share and POST /_fonoteka/api/v1/wishlist/share/regenerate, capturing the token path PHP returns as share:wishlist, category share.

(3) check_corpus.go, per RESEARCH Finding 5: for a route with status: ported, fail when any case's manifest status differs from the recorded status in its fixture, naming route, case and both numbers; pending routes are reported only as a count line (they are re-recorded by plans 13-02 to 13-05). check_corpus_test.go TestCheckCorpusPortedCaseStatus with a planted mismatch on a ported route (fails) and on a pending route (passes with the count line).

(4) manifest.yaml, per D-15: set the case status of GET /_fonoteka/api/v1/invitations/{token} public_invitation to 200 to match its fixture (the route stays pending until 13-02 re-records and ports it).

(5) parity/README.md: a "Phase 13 recording" section: QUEUE_CONNECTION=database for the nuxt-wishlist and nuxt-csv recordings, php_parity.sh rows for row goldens (digest queue, apparatus job rows), share:wishlist, the shared anonymous throttle:10,1 budget (reset before each anonymous recording, at most 10 inline-throttled cases per route), and the known summer_jobs.user_id difference from the Job contract table. Commit the fonoteka.go changes path-scoped.

(6) Planning docs (a separate docs-only commit in summercms.go; use Edit, never a whole-file Write), per D-01, D-02, D-06: ROADMAP Phase 13 **Repos:** becomes fonoteka.go, sm-user-plugin (submodule, D-09/D-11 additive exports) and summercms.go (13-01 framework gaps). Criterion 1 adds that the wishlist Discogs match/apply-release routes move to Phase 14 and the digest job body is Phase 14 (JOBS-03). Criterion 2 becomes notifications list, unread count and mark-read (one and all), with pruning as the Phase 14 fonoteka:prune-notifications console command. Criterion 3 adds that commit and mapping enqueue the CSV import and match jobs whose bodies are Phase 14 (JOBS-02). Criterion 4 adds that the live ai-credential/test and discogs-credential/test routes move to Phase 14. Phase 14 criterion 4 (Discogs) adds the wishlist match/apply-release routes, discogs-credential/test and the CSV row-edit Discogs pick; criterion 5 (AI) adds ai-credential/test and the backend global vision model behind the AI resolver's admin tier. REQUIREMENTS: API-03 notes match/apply-release in Phase 14; API-04 reads list, unread count, mark read, with pruning a Phase 14 console command; API-06 notes the live /test routes in Phase 14; INTG-01 and INTG-02 gain the moved routes. Leave every status column and the traceability table untouched. bash -n ../fonoteka.go/parity/php_parity.sh && grep -q 'QUEUE_CONNECTION:-sync' ../fonoteka.go/parity/php_parity.sh && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestCheckCorpusPortedCaseStatus|TestParityCorpus)$' && go -C ../fonoteka.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 && grep -q 'prune-notifications' .planning/REQUIREMENTS.md && grep -A14 '### Phase 14:' .planning/ROADMAP.md | grep -q 'apply-release' <fails_when>Non-zero exit: the script has a syntax error or lacks the override; a verbose run prints "--- FAIL" or "no tests to run" or lacks "--- PASS: TestCheckCorpusPortedCaseStatus" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a ported case-status mismatch, a secret or an unrecorded route; REQUIREMENTS.md lacks the prune wording or the Phase 14 section lacks apply-release.</fails_when> <acceptance_criteria> - grep -c 'share:wishlist' ../fonoteka.go/parity/capture-rules.yaml prints at least 1. - grep -A12 'invitations/{token} public_invitation' ../fonoteka.go/parity/manifest.yaml | grep -c 'status: 200' prints 1. - grep -c 'rows)' ../fonoteka.go/parity/php_parity.sh prints at least 1 and grep -c 'QUEUE_CONNECTION=database' ../fonoteka.go/parity/README.md prints at least 1. - grep -A14 '### Phase 13:' .planning/ROADMAP.md | grep -c 'sm-user-plugin' prints at least 1. - The REQUIREMENTS.md traceability rows for API-03..API-07 still read Phase 13 | Pending. - git log -1 --stat of the planning commit lists only .planning files, and the fonoteka.go commit lists only parity/ files. </acceptance_criteria> Recordings can show queued-not-run jobs and dump the rows D-13 compares, a ported route can no longer disagree with its fixture, and the roadmap and requirements match the locked Phase 13/14 boundary.

<threat_model>

Trust Boundaries

Boundary Description
HTTP request path → surf dispatch Untrusted path segments select the handler and its middleware chain
Request write transaction → River queue A queued job waits for a worker that ships later; it must neither fail the write nor be lost
Recorded fixtures and goldens → tide comparison Masks decide what the parity diff is allowed to ignore
Isolated PHP SQLite → row goldens The rows subcommand reads the parity database for committed goldens

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-23 Elevation of Privilege surf overlap dispatch high mitigate Members dispatch in registration order only after their literals and constraints match; each member runs its own wrapped middleware chain; no match is 404; TestOverlappingConstrainedRoutes covers middleware isolation and 404/405 (Task 1).
T-13-22 Denial of Service / Repudiation conga unregistered kinds high mitigate Unregistered kinds go through the insert-only client; empty or served queues are refused with ErrUnregisteredKindQueue so a job is never fetched and discarded; TestUnregisteredKindWithWorker and TestJobContractDispatchWhileWorkerRuns (Task 2).
T-13-24 Repudiation tide date and publication masks medium mitigate Each mask asserts the masked value's shape (real calendar date, Carbon +00:00, positive integer) and leaves every other path visible; negative tests prove a wrong stem, a bad date, a Z date and a string id still diff (Task 3).
T-13-25 Tampering lagoon prohibited rule medium mitigate Laravel semantics ported with a truth table including 0 and false; plan 13-03 records the wishlist 422 and its fuzz proves condition/shelf never persist (Task 3).
T-13-26 Information Disclosure php_parity.sh rows and share capture medium mitigate rows accepts one SELECT only and prints to stdout; share tokens are captured as {{share:wishlist}}; check_corpus --check-secrets stays in the verify (Task 4).
T-13-SC Tampering package installs low accept No new dependency in this plan; no npm/pip/cargo installs.
</threat_model>
- summercms.go: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' -count=1` and `go run ./cmd/summer docs:build --check` green. - fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus still 99 ported and passing. - Planning docs commit contains only ROADMAP.md and REQUIREMENTS.md.

<success_criteria>

  • Overlapping constrained routes register and dispatch with registration-order semantics; the route table is unchanged.
  • Workerless jobs can be dispatched while a worker runs and wait unworked; the job contract is pinned in one file.
  • prohibited, the Content-Disposition date mask and the notification publication masks exist with docs.
  • Parity tooling supports a database queue, row dumps, the wishlist share capture and the ported case-status check; ROADMAP and REQUIREMENTS carry D-01, D-02 and D-06. </success_criteria>
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md` when done.