Files
summercms/.planning/phases/14-domain-jobs-and-external-integrations/14-03-PLAN.md
2026-10-03 18:56:43 +02:00

250 lines
32 KiB
Markdown

---
phase: 14-domain-jobs-and-external-integrations
plan: 03
type: execute
wave: 3
depends_on: ["14-02"]
files_modified:
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_release_match_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/discogs_import_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_cover_fetch_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/discogs_routes_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/fonoteka_seed_test.go
- ../fonoteka.go/parity/fonoteka_reset.php
- ../fonoteka.go/parity/fixtures/routes/
- ../fonoteka.go/parity/upstream/scripts/
- ../fonoteka.go/parity/README.md
autonomous: true
requirements: [INTG-01]
estimate:
tokens: 330000
raw_tokens: 330000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-07 and INTG-01, the JWT routes `POST albums/match`, `POST albums/{id}/match`, `POST albums/{id}/apply-release`, `POST wishlist/albums/{id}/match`, `POST wishlist/albums/{id}/apply-release`, `POST albums/import/discogs` and `POST discogs-credential/test`, and the token route `POST /api/v1/fonoteka/albums/{id}/cover-price/discogs` (`inv.scope:write`, `throttle:12,1`) are mounted with routes.php's groups, constraints and middleware and pass the parity diff with their upstream sidecars; `expectedPortedRoutes` is 165 and 6 routes stay pending."
- "Each route checks in PHP's order: match/apply — album or wishlist scope 404 `{\"error\":\"Album not found\"}`, Discogs gate 503, the shared `fonoteka-discogs-missing:` limiter (60 per 60 s, 429 `{\"result\":\"error\",\"code\":\"too_many_requests\",\"retry_after\":N}`), then validation; import — gate, validation, then the `fonoteka-discogs-import:` limiter (20 per 60 s, 429 without retry_after); no outbound call happens before scope and gate pass."
- "Per INTG-01 host lock, every cover image is fetched only through `classes.CoverImporter` in AllowHostsMode for discogs.com and hosts ending in `.discogs.com`; the cover-price route ports AlbumCoverFetcher (discogs_id path, cover plus price suggestion, nothing_missing, ambiguous, rate_limited, refresh_price) and answers PHP's body with null values removed."
- "apply-release writes only empty fields unless `overwrite_all` is set, `cover_only` touches only the cover, and `dry_run` writes nothing and returns the draft (album route) exactly as PHP; the wishlist twin never returns a `draft` key."
- "`discogs-credential/test` answers `{\"ok\":true}` for a valid inline or stored token, PHP's Polish rejected-token message for a 401, the rate-limited message on a 429 beyond budget, and the disabled body with no upstream call when Discogs is off; the response never contains the token."
- "The Phase 12 and 13 route-table tests exclude the routes `TestRouteTablePhase14` pins, `phase14Absent` keeps only `POST /ai-credential/test`, and the Phase 10.1 admin Discogs stubs are unchanged (PHP has no such admin actions)."
- statement: "Edge (INTG-01 idempotency): applying the same release to the same album twice fills nothing on the second call and imports no second cover, matching PHP's second response."
verification: backstop
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher.go"
provides: "AlbumCoverFetcher port"
contains: "CoverFetcher"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go"
provides: "AlbumReleaseMatch, AlbumReleaseMatchDraft, AlbumApplyRelease"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go"
provides: "InboundLimits (surf.MemoryStore keys fonoteka-discogs-missing, fonoteka-discogs-import)"
contains: "fonoteka-discogs-missing:"
- path: "../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go"
provides: "TestRouteTablePhase14, phase14Routes"
contains: "TestRouteTablePhase14"
key_links:
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go"
via: "JWT group mounts the match/apply handlers with the [0-9]+ id constraint"
pattern: "AlbumReleaseMatch"
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_cover_fetch_controller.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go"
via: "cover bytes only through CoverImporter's AllowHosts fetch"
pattern: "CoverImporter"
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go"
via: "one surf.MemoryStore per plugin instance shared by album and wishlist routes"
pattern: "NewMemoryStore"
prohibitions:
- requirement_id: INTG-01
category: values
statement: "apply-release MUST NOT overwrite a field the collector already filled unless the request sets overwrite_all, and dry_run MUST NOT write anything"
status: resolved
verification: test
---
## Phase Goal
ROADMAP Phase 14 goal (verbatim, not in user-story form): The domain-specific River jobs (CSV import write, Discogs match, wishlist digest), the reindex command, the Discogs client and AI cover recognition are ported on top of the Phase 11 jobs/realtime/search infrastructure and the Phase 13 API surface they serve.
This plan's slice: a collector matches an album or a wishlist item against Discogs, applies a release, imports an album from a Discogs link or barcode, tests their Discogs token, and the MCP client fetches a cover and market price, all with PHP's responses (INTG-01; ROADMAP SC4 as reworded by D-07).
<objective>
Mount the eight Discogs routes on top of the 14-02 client and domain classes, with PHP's check order, the in-controller limiters and the host-locked cover fetch; record their cases with upstream sidecars and flip them to ported.
Purpose: these are the Nuxt match dialog, the import box and the MCP `fetch_album_cover_and_price` tool. Decisions: D-07, D-11, D-15, D-19.
Output: controllers, routes, AlbumCoverFetcher, route-table test, recordings; ported count 165.
Repo: fonoteka.go only. Commits path-scoped; 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/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-02-SUMMARY.md
@../fonoteka.go/plugins/golem15/fonoteka/routes.go
<interfaces>
- From 14-02: `discogs.ForUser`, `(*Client).SearchByQuery/SearchByBarcode/GetRelease/GetMasterVersions/GetPriceSuggestions/GetIdentity`, `*RateLimitError`, `ErrTokenRejected`, `MapRelease/MapSearchResult/MapMasterVersion`, `ParseInput`, `ScoreRelease`, `Applicator.Apply → ApplyResult{Filled, Remaining, Draft}`, `ImportResolver`, `ResolvePriceSuggestion`, `discogstest.FakeClock`; parity sidecar hook in replayPortedRoute; `PARITY_UPSTREAM_CA` in php_parity.sh; script files under parity/upstream/scripts.
- Existing: `api.requestScope`, `writeJSON`, `writeValidationFailed`, `writeWinterHTTPError`, `marshalNoEscape` (controllers/api); `classes.DiscogsAllowed`, `ResolveDiscogsConfig`, album access helpers in classes/access.go and the wishlist resolver (classes/wishlist_resolver.go); `classes.CoverImporter`; `surf.NewMemoryStore(sweep)` and `(*MemoryStore).Attempt(key, max, decay) (ok bool, remaining int, retryAfter time.Duration)`; `PubfailCounter` wiring on Plugin as the precedent for per-plugin state.
- PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{routes.php (lines 207-246, 296-310, 483-496), controllers/api/AlbumReleaseMatchController.php, controllers/api/WishlistReleaseMatchController.php, controllers/api/DiscogsImportController.php, controllers/api/AlbumCoverFetchController.php, controllers/api/DiscogsCredentialController.php (test), classes/discogs/AlbumCoverFetcher.php, classes/DiscogsGate.php, tests/unit/{CoverImporterTest,AlbumReleaseApplicatorCoverOnlyTest,AlbumReleaseApplicatorDryRunTest}.php}; Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (lines 315-440, 601-620); MCP /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 277-337).
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- classes/discogs: `CoverFetcher` (AlbumCoverFetcher port), `NewCoverFetcher`, `(*CoverFetcher).Fetch(ctx, album, opts) (CoverFetchOutcome, error)`, `CoverFetchOutcome`.
- controllers/api: `AlbumReleaseMatchDraft`, `AlbumReleaseMatch`, `AlbumApplyRelease`, `WishlistReleaseMatch`, `WishlistApplyRelease`, `DiscogsImport`, `AlbumCoverPriceFetch`, `DiscogsCredentialTest`, `InboundLimits`, `NewInboundLimits`.
- Routes: JWT `POST /albums/match`, `POST /albums/{id}/match`, `POST /albums/{id}/apply-release`, `POST /wishlist/albums/{id}/match`, `POST /wishlist/albums/{id}/apply-release`, `POST /albums/import/discogs`, `POST /discogs-credential/test`; token `POST /albums/{id}/cover-price/discogs` (`inv.scope:write`, `throttle:12,1`).
- Tests: `TestRouteTablePhase14`, `TestDiscogsMatchRoute`, `TestDiscogsInboundLimits`, `TestApplyReleaseModes`, `TestDiscogsImportRoute`, `TestCoverPriceRoute`, `TestDiscogsCredentialTestRoute`, `TestCoverFetcherHostLock`.
- Parity: recorded cases 4-15 of the research D-11 table with sidecars; manifest flips (8 routes).
## Assumptions
- Research Open Question 5, resolved by the orchestrator default: the Phase 10.1 admin `discogsLookup` and `discogsSync` stubs stay as they are; PHP has no such admin actions, and no task here touches them.
- The inbound limiters live in process memory (surf.MemoryStore, the PubfailCounter precedent); several app instances multiply the inbound budget, as already accepted for pubfail in Phase 13. The outbound Discogs budget stays shared in Postgres (14-02).
- Inbound-limit 429 cases (60 or 20 calls) are proven by Go tests, not by recording dozens of PHP requests.
<tasks>
<task type="tracer">
<name>Task 1: A collector asks for Discogs matches for one of their albums and gets PHP's scored candidates</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/discogs_routes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/upstream/scripts/</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumReleaseMatchController.php (match, the limiter, validation, error mapping), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 234-242), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (lines 315-440), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go (handler factory, requestScope, error writers), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go (JWT group line 45), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (pubfailCounter wiring), ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go (phase12Routes, phase12Universe), ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go (phase13Universe, phase14Absent, the 404 subtest), summercms.go modules/surf/limiter_store.go, ../fonoteka.go/parity/manifest.yaml (the pending `POST /_fonoteka/api/v1/albums/{id}/match jwt` entry and its fixture)</read_first>
<action>Per D-07, D-11, D-15 (thinnest path for the album match).
(1) controllers/api/inbound_limits.go `InboundLimits{store *surf.MemoryStore}`, `NewInboundLimits()` (one sweep interval of 1 minute), methods `DiscogsMissing(key string) (ok bool, retryAfter int)` (key prefix `fonoteka-discogs-missing:` plus user id, 60 per 60 s) and `DiscogsImport(key string) bool` (`fonoteka-discogs-import:`, 20 per 60 s); retry_after is the seconds rounded up from Attempt's retryAfter (Laravel availableIn). plugin.go holds one InboundLimits created at Register, beside pubfail, and passes it to the handlers.
(2) release_match_controller.go `AlbumReleaseMatch(app, limits)`: requestScope; album lookup through the existing accessible-album and active-collection scope (missing or foreign → 404 `{"error":"Album not found"}`); DiscogsAllowed false → PHP's 503 `discogs_disabled` body; limiter → 429 body with retry_after; validation exactly as PHP (q required, the year and medium rules) → PHP's 422 body; then discogs.ForUser, SearchByQuery, MapSearchResult plus ScoreRelease, answer PHP's candidate list with `q`, `year`, `year_delta`, `medium`; `*RateLimitError` → 429 `discogs_rate_limited`, ErrTokenRejected → 502 `discogs_token_rejected`, other errors → PHP's mapped body. Typed response structs or `*csv.Map` keep PHP key order. routes.go mounts `POST /albums/{id}/match` on the JWT group with the `[0-9]+` constraint.
(3) Route tables: routes_table_phase14_test.go declares `phase14Routes` (method, path, group, scope, throttle, routes.php line) starting with this route and `TestRouteTablePhase14` (each entry mounted once on its group with its middleware, constraint refuses `abc` and `1x`); phase12Universe and phase13Universe skip any key in phase14Routes so each route is pinned once; phase14Absent is untouched until a listed route lands.
(4) Parity: seed alice's Discogs credential and an album with a known title in both seeds (14-02 seeded the token); script files for `/database/search?q=…&type=release` 200, a 401 and a 429 with `Retry-After: 30`; record with the proxy: 200 candidates, 422 missing q (no upstream), 502 token rejected, 429 discogs_rate_limited (budget exhausted); flip the route; expectedPortedRoutes 158. discogs_routes_test.go `TestDiscogsMatchRoute` (foreign album 404 makes no upstream call, gate off 503 makes none) and `TestDiscogsInboundLimits` (the 60th call passes, the 61st answers 429 with retry_after; the key is shared with the wishlist routes added in Task 2).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' &amp;&amp; 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</automated>
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestDiscogsMatchRoute, TestDiscogsInboundLimits, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 158.
- `grep -c 'fonoteka-discogs-missing:' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go` prints 1.
- The manifest entry `POST /_fonoteka/api/v1/albums/{id}/match jwt` has `status: ported` and at least four cases, three with a sidecar file on disk.
</acceptance_criteria>
<done>The album match route works end to end through the inbound limiter, the Discogs client and the scorer, and replays PHP's recorded exchanges offline.</done>
</task>
<task type="auto">
<name>Task 2: A collector applies a Discogs release to an album or a wishlist item, previews it, or matches a draft before saving</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_release_match_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/discogs_routes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/upstream/scripts/</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumReleaseMatchController.php (matchDraft, applyRelease), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/WishlistReleaseMatchController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/AlbumReleaseApplicator.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 207-211), ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/applicator.go (14-02), ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go (Task 1), /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts (MatchReleaseDialog calls)</read_first>
<action>Per D-07 and INTG-01.
(1) release_match_controller.go: `AlbumReleaseMatchDraft(app, limits)` for `POST /albums/match` (no album; candidates scored against the posted draft) and `AlbumApplyRelease(app, limits)` for `POST /albums/{id}/apply-release`: scope 404 → gate 503 → limiter → validation (release id, `overwrite_all`, `cover_only`, `dry_run` booleans as PHP) → GetRelease (nil → 200 `discogs_no_match` body) → MapRelease → `Applicator.Apply(ctx, album, mapped, overwriteAll, coverOnly, dryRun)` inside one lagoon.Transaction with the album broadcast emitted as Phase 12 album updates emit → 200 `{data, filled, remaining, draft}` in PHP order. Rate-limit and token errors map as in Task 1.
(2) wishlist_release_match_controller.go: `WishlistReleaseMatch` and `WishlistApplyRelease` scope the album through the active wishlist resolver (PHP's ActiveWishlistResolver), share the `fonoteka-discogs-missing:` key, reuse the album handlers' core, and never return a `draft` key or accept `dry_run` where PHP does not. routes.go mounts `POST /albums/match`, `POST /albums/{id}/apply-release`, `POST /wishlist/albums/{id}/match`, `POST /wishlist/albums/{id}/apply-release` on the JWT group (`[0-9]+` on id); add them to phase14Routes and remove the two wishlist entries from phase14Absent.
(3) Parity: script files for `/releases/{id}` 200 and 404 and the cover GET on i.discogs.com (a small JPEG, served by the scripted proxy); record cases 9-12 of the research D-11 table: albums/match 200; apply-release 200 fill-empty, `dry_run:true`, `cover_only:true`, `overwrite_all:true`, and 200 `discogs_no_match`; wishlist match and apply-release success twins; flip the four routes; expectedPortedRoutes 162. Seed albums in both seeds with some fields filled so fill-empty and overwrite differ.
(4) Tests in discogs_routes_test.go: `TestApplyReleaseModes` (fill-empty leaves a filled field, overwrite_all replaces it, cover_only changes only the cover, dry_run writes no row and imports no cover, a second identical apply fills nothing and imports no second cover), and the wishlist twin answers without `draft`.</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestApplyReleaseModes|TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' &amp;&amp; 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</automated>
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestApplyReleaseModes, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 162.
- `grep -c '"POST /wishlist/albums/{id}/match"' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go` prints 0.
- The apply-release manifest entry carries cases for fill-empty, dry_run, cover_only, overwrite_all and discogs_no_match, each recorded with a sidecar.
</acceptance_criteria>
<done>Applying, previewing and draft-matching releases behave as PHP for albums and wishlist items, with covers fetched only from Discogs hosts.</done>
</task>
<task type="auto">
<name>Task 3: A collector imports an album from a Discogs link or barcode and tests their token, and the MCP client fetches a cover and market price</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/discogs_import_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_cover_fetch_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/discogs_routes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/upstream/scripts/, ../fonoteka.go/parity/README.md</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/DiscogsImportController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumCoverFetchController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/AlbumCoverFetcher.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsImportResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/PriceSuggestionResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/DiscogsCredentialController.php (test, lines 98-140), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php (discogs keys), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 246, 310, 483-496), /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (lines 277-337), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go (token group line 240), ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/parity/manifest.yaml (pending import, cover-price and discogs-credential/test entries and their fixtures)</read_first>
<action>Per D-07, D-11, D-19 and INTG-01.
(1) discogs_import_controller.go `DiscogsImport(app, limits)` for `POST /albums/import/discogs`: gate 503 → validation (`input` as PHP) → `fonoteka-discogs-import:` limiter (429 `{"result":"error","code":"too_many_requests"}`) → ImportResolver: a release URL or id answers the draft, a barcode with many hits answers candidates, zero hits answers 200 `no_match` with `barcode`, a master answers its versions; errors map as PHP.
(2) classes/discogs/cover_fetcher.go ports AlbumCoverFetcher: `NewCoverFetcher(client, importer *classes.CoverImporter, db, clock)` and `Fetch(ctx, album, CoverFetchOptions{RefreshPrice bool}) (CoverFetchOutcome, error)` covering the discogs_id path (release, cover import, price suggestion through ResolvePriceSuggestion and the market currency), `nothing_missing`, `ambiguous`, `rate_limited`, `no_source` and `refresh_price`, persisting what PHP persists. Cover bytes only through CoverImporter (AllowHosts discogs.com and `.discogs.com`). album_cover_fetch_controller.go `AlbumCoverPriceFetch(app)` answers PHP's body with null values dropped (validation 422 `{"errors":…}`, album 404 `{"error":"Album not found"}`). routes.go mounts it on the token group with `inv.scope:write` and `throttle:12,1`.
(3) credentials_controller.go `DiscogsCredentialTest(app)` for `POST /discogs-credential/test`: gate off answers the disabled body with no upstream call; an inline `token` or the stored credential calls GetIdentity; 200 → `{"ok":true}`, ErrTokenRejected → `{"ok":false,"error":"Token Discogs jest nieprawidłowy lub wygasł."}`, a rate limit → PHP's rate-limited message; the token never appears in the body or logs. routes.go mounts it; remove it from phase14Absent and add all three routes to phase14Routes.
(4) Parity: script files for barcode search (many and zero hits), master versions, price suggestions (and the empty object), identity 200, 401 and 429; record the research D-11 cases 13-15 (import draft, candidates, no_match, 422, cover-price fetched, nothing_missing, ambiguous, rate_limited, refresh_price, credential ok, rejected, rate limited, disabled), re-recording the existing live-vendor `discogs-credential/test` case through the proxy (D-19); fix any manifest status that differs from its fixture; flip the three routes; expectedPortedRoutes 165, pending 6. README notes the Discogs script files.
(5) Tests: `TestDiscogsImportRoute` (limiter after validation: an invalid body never counts; the 21st valid call is 429), `TestCoverPriceRoute` (scope write required, throttle 12 per minute, nulls dropped), `TestDiscogsCredentialTestRoute` (no upstream when disabled; token absent from every body), cover_fetcher_test.go `TestCoverFetcherHostLock` (a release whose image URL points at another host imports nothing and makes no request to it).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/discogs/... -count=1 -race -v -run '^(TestCoverFetcherHostLock)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsImportRoute|TestCoverPriceRoute|TestDiscogsCredentialTestRoute|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' &amp;&amp; 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</automated>
<fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCoverFetcherHostLock, TestDiscogsImportRoute, TestCoverPriceRoute, TestDiscogsCredentialTestRoute, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a case-status mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 165.
- `grep -c 'cover-price/discogs' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 1 and that line carries `inv.scope:write` and `throttle:12,1`.
- `grep -A3 'var phase14Absent' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go | grep -c 'discogs-credential/test'` prints 0.
- The re-recorded `POST /_fonoteka/api/v1/discogs-credential/test jwt` fixture has a sidecar and no live Discogs token.
</acceptance_criteria>
<done>All eight Discogs routes pass the parity diff offline, the cover fetch stays host-locked, and only the AI routes remain absent from the router.</done>
</task>
</tasks>
## Canon referrals (not minted as prohibitions)
- IDOR on album ids is canon (OWASP access control) — covered by the scope-first ordering truth and /gsd-secure-phase.
- SSRF through cover URLs is canon — covered by CoverImporter's host lock and /gsd-secure-phase.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client → album and wishlist ids | Ids are user input; albums belong to collections |
| Client → Discogs-backed routes | Each call can spend the shared Discogs budget |
| Discogs release JSON → cover URL | Image URLs come from an outside service |
| Personal token → cover-price route | MCP clients act with a scoped token |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-16 | Elevation of Privilege | album and wishlist match/apply | high | mitigate | Scope check first (404 before gate, limiter or upstream); TestDiscogsMatchRoute foreign-album case (Task 1). |
| T-14-17 | Denial of Service | Discogs routes | medium | mitigate | fonoteka-discogs-missing 60/60 s shared, fonoteka-discogs-import 20/60 s, throttle:12,1 on cover-price; TestDiscogsInboundLimits, TestDiscogsImportRoute, TestCoverPriceRoute (Tasks 1, 3). |
| T-14-18 | Tampering | cover download | high | mitigate | Only CoverImporter AllowHosts discogs.com / .discogs.com; TestCoverFetcherHostLock (Task 3). |
| T-14-19 | Tampering | apply-release | medium | mitigate | Fill-empty default, overwrite only with overwrite_all, dry_run writes nothing; TestApplyReleaseModes (Task 2). |
| T-14-20 | Elevation of Privilege | token cover-price | high | mitigate | `inv.scope:write` on the route; TestRouteTablePhase14 and TestCoverPriceRoute (Task 3). |
| T-14-21 | Information Disclosure | discogs-credential/test | medium | mitigate | Body is ok/error only, token never echoed or logged; TestDiscogsCredentialTestRoute (Task 3). |
| T-14-SC | Tampering | package installs | low | accept | No new module or package. |
</threat_model>
<verification>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; corpus 165 ported, 0 failing, 6 pending; check_corpus `--require-recorded --check-secrets` green.
</verification>
<success_criteria>
- Eight Discogs routes ported with PHP bodies, check order and limiters.
- Every Discogs and cover exchange replays offline from PHP-recorded sidecars.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-03-SUMMARY.md` when done.
</output>