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

32 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
14-domain-jobs-and-external-integrations 03 execute 3
14-02
../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
true
INTG-01
tokens raw_tokens tasks confidence
330000 330000 3 low
truths artifacts key_links prohibitions
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 verification
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. backstop
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher.go AlbumCoverFetcher port CoverFetcher
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go AlbumReleaseMatch, AlbumReleaseMatchDraft, AlbumApplyRelease
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go InboundLimits (surf.MemoryStore keys fonoteka-discogs-missing, fonoteka-discogs-import) fonoteka-discogs-missing:
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go TestRouteTablePhase14, phase14Routes TestRouteTablePhase14
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/release_match_controller.go JWT group mounts the match/apply handlers with the [0-9]+ id constraint AlbumReleaseMatch
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_cover_fetch_controller.go ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go cover bytes only through CoverImporter's AllowHosts fetch CoverImporter
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/plugin.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go one surf.MemoryStore per plugin instance shared by album and wishlist routes NewMemoryStore
requirement_id category statement status verification
INTG-01 values apply-release MUST NOT overwrite a field the collector already filled unless the request sets overwrite_all, and dry_run MUST NOT write anything resolved 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).

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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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 - 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).

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.
Task 1: A collector asks for Discogs matches for one of their albums and gets PHP's scored candidates ../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/ /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) 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). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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 <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> <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> 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.

Task 2: A collector applies a Discogs release to an album or a wishlist item, previews it, or matches a draft before saving ../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/ /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) 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. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestApplyReleaseModes|TestDiscogsMatchRoute|TestDiscogsInboundLimits|TestRouteTablePhase14|TestRouteTablePhase13)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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 <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> <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> Applying, previewing and draft-matching releases behave as PHP for albums and wishlist items, with covers fetched only from Discogs hosts.

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 ../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 /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) 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). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/discogs/... -count=1 -race -v -run '^(TestCoverFetcherHostLock)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestDiscogsImportRoute|TestCoverPriceRoute|TestDiscogsCredentialTestRoute|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && 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 <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> <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> 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.

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

<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>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-03-SUMMARY.md` when done.