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.
351 lines
48 KiB
Markdown
351 lines
48 KiB
Markdown
---
|
|
phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements: [API-03, API-04, API-05, API-06, API-07]
|
|
estimate:
|
|
tokens: 240000
|
|
raw_tokens: 240000
|
|
tasks: 4
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "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: "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."
|
|
verification: backstop
|
|
artifacts:
|
|
- path: "modules/surf/overlap.go"
|
|
provides: "overlap family detection and the constraint-aware dispatcher used by compile"
|
|
contains: "SetPathValue"
|
|
- path: "modules/conga/conga.go"
|
|
provides: "ErrUnregisteredKindQueue and insert-only routing for unregistered kinds"
|
|
contains: "ErrUnregisteredKindQueue"
|
|
- path: "modules/lagoon/validate_rules.go"
|
|
provides: "prohibited rule"
|
|
contains: "\"prohibited\""
|
|
- path: "modules/tide/centrifugo_golden.go"
|
|
provides: "payload created_at and id masks"
|
|
contains: "$.data.payload.created_at"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go"
|
|
provides: "the Phase 13 job kinds, queues, labels and args types"
|
|
contains: "fonoteka.wishlist.digest"
|
|
- path: "../fonoteka.go/parity/php_parity.sh"
|
|
provides: "QUEUE_CONNECTION override and rows subcommand"
|
|
contains: "QUEUE_CONNECTION:-sync"
|
|
key_links:
|
|
- from: "modules/surf/router.go"
|
|
to: "modules/surf/overlap.go"
|
|
via: "compile registers overlap families through one dispatcher pattern instead of one mux.Handle per member"
|
|
pattern: "overlap"
|
|
- from: "modules/conga/conga.go"
|
|
to: "modules/conga/client.go"
|
|
via: "insertClient picks the insert-only client for a kind with no registered job; knownQueues decides the served-queue refusal"
|
|
pattern: "knownQueues"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go"
|
|
to: "modules/conga/worker.go"
|
|
via: "StartWorker with the app jobs, then Dispatch of CsvImportArgs"
|
|
pattern: "StartWorker"
|
|
prohibitions:
|
|
- requirement_id: API-05
|
|
category: transparency
|
|
statement: "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)"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: API-03
|
|
category: transparency
|
|
statement: "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)"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: API-07
|
|
category: safety
|
|
statement: "Framework code, tests, READMEs and docs changed here MUST NOT name the consuming application; examples use neutral names (acme, shelves, items)"
|
|
status: resolved
|
|
verification: 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.
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
- 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"`.
|
|
</interfaces>
|
|
</context>
|
|
|
|
## 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
|
|
|
|
<reversibility rating="costly">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.</reversibility>
|
|
|
|
| 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.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: The app's four overlapping wishlist route pairs boot on one router and each request reaches the PHP-equivalent route</name>
|
|
<reversibility rating="reversible">Internal to surf.compile; no exported API changes and non-conflicting routes register exactly as before.</reversibility>
|
|
<files>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</files>
|
|
<read_first>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</read_first>
|
|
<action>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.</action>
|
|
<verify>
|
|
<automated>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)$'</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>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</name>
|
|
<reversibility rating="costly">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.</reversibility>
|
|
<files>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</files>
|
|
<read_first>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)</read_first>
|
|
<action>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.</action>
|
|
<verify>
|
|
<automated>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)$'</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>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</name>
|
|
<files>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</files>
|
|
<read_first>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)</read_first>
|
|
<action>(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.</action>
|
|
<verify>
|
|
<automated>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)$'</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>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</name>
|
|
<precondition>`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).</precondition>
|
|
<files>../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</files>
|
|
<read_first>../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)</read_first>
|
|
<action>(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.</action>
|
|
<verify>
|
|
<automated>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'</automated>
|
|
<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>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- 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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md` when done.
|
|
</output>
|