Files
summercms/.planning/phases/14-domain-jobs-and-external-integrations/14-03-SUMMARY.md
2026-10-03 22:25:35 +02:00

17 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, app_repo_head_before, app_repo_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed, status
phase plan subsystem tags requires provides affects actuals plan_head_before plan_head_after app_repo_head_before app_repo_head_after tech-stack key-files key-decisions patterns-established requirements-completed duration completed status
14-domain-jobs-and-external-integrations 03 api
discogs
release-match
apply-release
import
cover-price
mcp
inbound-limiter
parity
upstream-sidecar
tide
phase provides
14-domain-jobs-and-external-integrations 14-02 classes/discogs client, limiter, mapper, scorer, applicator, import resolver, price resolver; per-case upstream sidecar replay; the csv-discogs seed precedent
phase provides
14-domain-jobs-and-external-integrations 14-01 fetchguard.Client with the WithTransport seam, tide sidecars and summer parity:upstream
Eight Discogs routes ported with PHP's check order, bodies and limiters: albums/{id}/match, albums/match, albums/{id}/apply-release, wishlist/albums/{id}/match, wishlist/albums/{id}/apply-release, albums/import/discogs, discogs-credential/test (JWT) and albums/{id}/cover-price/discogs (token, inv.scope:write, throttle:12,1)
controllers/api InboundLimits (fonoteka-discogs-missing: 60/60 s shared by album and wishlist routes, fonoteka-discogs-import: 20/60 s)
classes/discogs CoverFetcher (AlbumCoverFetcher port) and classes.NewGuardedCoverImporter (cover downloads through a fetchguard.Client that honours the replay seam)
50 PHP-recorded parity cases with 31 upstream sidecars (covers as !!binary bodies); the additive discogs seed extra in both seeds
TestRouteTablePhase14 pinning the Discogs routes; the Phase 12 and 13 tables skip them
tide: binary upstream bodies and *_url upload-URL masking (framework)
14-04 AI recognition and ai-credential/test
14-06 unit tests and gate
tokens tasks commits app_repo_commits
97200 3 1 3
2ee97c62f6 5101ecb49c ef34015a223a67e649e99bac410fb728337c0ee3 28349433d77ba16c0dec7c87e41ef486996169f2
added patterns
Discogs routes check in PHP's order (scope, gate, bucket, validation; import: gate, validation, bucket) and reach Discogs only through discogs.ForUser on the request context, so the replay's upstream fake answers every call
Per-app in-memory inbound limiters are published in the app registry (inboundLimits(app)), like pubfailCounter
A recording run keeps one PHP server up and starts one scripted proxy per case; a case that must not reach a vendor records with an empty script and its sidecar is discarded
created modified
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.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/classes/discogs/cover_fetcher.go
../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/cover_fetcher_test.go
../fonoteka.go/plugins/golem15/fonoteka/discogs_routes_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go
../fonoteka.go/parity/upstream/scripts/discogs-*.yaml (12 scripts)
../fonoteka.go/parity/fixtures/routes/ (50 fixtures, 31 sidecars)
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go
../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go
../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
../fonoteka.go/plugins/golem15/fonoteka/realtime_smoke_test.go
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/parity_contract_test.go
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/README.md
modules/tide/upstream.go
modules/tide/normalize.go
modules/tide/README.md
docs/services/parity-testing.md
Parity over the plan's wording where they disagree: a second identical apply re-imports the release's covers (PHP has no dedupe), and dry_run writes the covers and the catalog fields without a review UI (genre_id, barcode, edition, discogs_id, format, market price), staging only year, label, catalog_number, country and the tracklist
apply-release runs without an enclosing transaction, as PHP: the cover downloads would otherwise hold the album row lock across network I/O; each save broadcasts through the model hooks as PHP's model events do
Discogs-route covers go through classes.NewGuardedCoverImporter (a fetchguard.Client with PHP's GuzzleHttp/7 User-Agent) so the replay answers them from the sidecar; the Phase 12 album cover imports keep fetchguard.Fetch (deferred-items.md)
NewCoverFetcher takes the Discogs Env, the importer and the bucket; the per-user client is built after the gate, as PHP's forUser runs inside fetch()
discogs is an additive seed extra (like albums) in fonoteka_reset.php and the Go seed; with the album state A Love Supreme carries discogs_id 2002
The import route maps any failure other than a rejected token or a rate limit to Winter's 500 page, because PHP catches only those two
tide sidecars carry binary bodies as YAML !!binary; script files may answer with body: !!binary <base64>
Upload-URL shape masking applies to url and every *_url key (cover_url)
INTG-01
54min 2026-10-03 complete

Phase 14 Plan 03: Discogs routes Summary

All eight Discogs routes now run in Go with PHP's check order, bodies and limiters: album and wishlist release match and apply, draft match, the import box, the token test and the MCP cover-and-price fetch. They replay offline from 50 PHP-recorded cases whose Discogs exchanges, cover images included, are asserted request by request.

Performance

  • Duration: 54 min
  • Started: 2026-10-03T19:27Z
  • Completed: 2026-10-03T20:21Z
  • Tasks: 3
  • Commits: 3 in fonoteka.go, 1 code commit in summercms.go (plus this summary)
  • Files: 116 changed in fonoteka.go (+5300/-149), 6 in summercms.go (+115/-10)

Accomplishments

  • Match (D-07, INTG-01). POST albums/{id}/match, POST albums/match (scored against an unsaved draft album) and the wishlist twin. Each checks in PHP's order: the album or wishlist scope 404, the gate 503, then the shared fonoteka-discogs-missing: bucket (60 per 60 s, 429 with retry_after), then validation 422. After that comes the search, the year and medium filters over the first ten results, and the scores merged into each candidate in key order.

  • Apply. POST albums/{id}/apply-release and the wishlist twin run the 14-02 Applicator. Fill-empty is the default; overwrite_all, cover_only and the album route's dry_run behave as PHP's applicator. A missing release answers 200 discogs_no_match. The wishlist route never reads dry_run and never answers draft. Rejected tokens map to 502, rate limits to 429 discogs_rate_limited and other failures to 502 discogs_unavailable, logged by class only.

  • Import. POST albums/import/discogs checks the gate, then validation, then its own fonoteka-discogs-import: bucket (20 per 60 s, 429 without retry_after), so an invalid body never counts. It answers the resolver's draft, candidates or no_match.

  • Cover and price (MCP). classes/discogs.CoverFetcher ports AlbumCoverFetcher:

    • certainty through the stored id and corroboration, or exactly one barcode hit
    • nothing_missing, cover_already_present and price_already_present handled independently
    • suggestion-first market price with market_price_source
    • one cover through the host-locked importer, with earlier cover_import_failures kept
    • the attached-cover report after an unexpected error

    The token route (inv.scope:write, throttle:12,1) answers the outcome with null values dropped.

  • Token test. POST discogs-credential/test sends one /oauth/identity request with the inline token (trimmed and validated; a malformed one gets the Winter 500 page) or the stored one. A refused gate gets the disabled message without a request. No body or log carries the token.

  • Route surface. TestRouteTablePhase14 pins the eight routes once each, with their group middleware, scope, throttle and {id} constraint. phase14Absent keeps only POST /ai-credential/test, and the Phase 08 isolation subtests now assert the real surfaces. The Phase 10.1 admin Discogs stubs are untouched.

  • Parity. 50 cases were recorded against PHP through summer parity:upstream, with 31 sidecars. This includes the re-recorded discogs-credential/test case, which no longer calls the live vendor. The corpus has 165 routes ported and passing, 6 pending, and check_corpus --require-recorded --check-secrets is green.

Task Commits

fonoteka.go:

  1. Task 1 (tracer): album match, inbound limits, route table: d8cd725 (feat)
  2. Task 2: draft match, apply-release, wishlist twins, guarded cover importer: 6a995a6 (feat)
  3. Task 3: import, cover-price, credential test, CoverFetcher, README: 2834943 (feat)

summercms.go:

  • 5101ecb (feat): tide keeps binary upstream bodies and masks every *_url upload by shape (README and parity-testing docs)

Tracer gate: after Task 1, <verify> was re-run end to end and passed (vet, the four named tests with -race, the corpus, check_corpus), so the expansion tasks went ahead.

Deviations from Plan

Auto-fixed issues

1. [Rule 3 - Blocking] tide could not carry a cover image or compare cover_url

  • Found during: recording design (Task 2/3)
  • Issue: Sidecar bodies are YAML strings, so a JPEG response was corrupted on write. The normalizer masked only url and thumb_url, so the cover-price route's random cover_url path could never match.
  • Fix: A non-UTF-8 response body is written as !!binary, never masked, and replayed byte for byte. Every *_url key gets the upload-URL shape check. Tests, the tide README and docs/services/parity-testing.md were updated, and TestDocsTree and docs:build --check are green.
  • Commit: 5101ecb (summercms.go)

2. [Rule 2 - Correctness] Discogs-route covers through a seam-aware client

  • Issue: CoverImporter's default fetchguard.Fetch ignores fetchguard.WithTransport, so the replay would have reached i.discogs.com.
  • Fix: classes.NewGuardedCoverImporter is a fetchguard.Client in AllowHostsMode with the importer's cap and timeout and PHP's recorded User-Agent: GuzzleHttp/7. The Discogs routes use it. The Phase 12 album routes are unchanged; see deferred-items.md.
  • Commit: 6a995a6

3. [Rule 1 - Test fragility] Two existing tests broke once the package created more rows and jobs

  • The TestRouteTablePhase13 routing checks sent the literal id 12, and a routed DELETE wishlist/albums/12 could remove another test's album. They now use unusedNumericID (2000000000). Commit 6a995a6.
  • TestAlbumBroadcastSmoke's worker drained broadcast jobs that earlier tests left in the shared queue, so it missed its 5 s window. It now drops those stale jobs first. Amended into d8cd725.

4. [Rule 1 - Own omission] TestParityContract allow-list

  • Newly ported routes must be listed. phase14SeedRoutes was added and amended into d8cd725, so every commit stays green.

Plan statements that conflict with PHP (parity kept)

5. Idempotency truth. The plan says a second identical apply "imports no second cover, matching PHP's second response". PHP's recorded second response (twice case) does fill nothing again, but it re-imports both covers (filled: ["cover"], four photos). Go matches PHP, and TestApplyReleaseModes asserts that no field is filled again and that the covers are appended as PHP appends them.

6. "dry_run writes nothing" (prohibition). PHP's dry run, recorded as the dry-run case, stages only year, label, catalog_number, country and the tracklist. It still writes the covers, genre_id, barcode, edition, discogs_id, format and the market price. Go does the same, and the staged fields are never written. The verifier should treat the prohibition as "never writes a staged field".

7. No transaction around apply. The plan asked for one lagoon.Transaction. PHP has none, and a transaction would hold the album row lock while up to five covers download. Broadcasts fire from each model save, as PHP's model events do. They are not collapsed into one Phase 12-style event.

Plan details refined

8. NewCoverFetcher(env, importer, bucket) replaces (client, importer, db, clock), because the per-user client is created after the gate. CoverFetchOptions{ForceCover, ForcePrice} mirrors the route's force/refresh_price mapping.

9. Seeds: discogs is an additive extra in fonoteka_reset.php and the Go seed, and it also sets A Love Supreme's discogs_id to 2002. The old pending fixtures came from older seeds, and some came from live vendors. Each was replaced by a fresh case recording, and every route has more cases than the plan's minimum.

10. The import route answers failures PHP does not catch with Winter's 500 page.

Total deviations: 10. Four are auto-fixes, three are plan conflicts resolved for parity, and three are refinements. Impact: the only framework change is the small, documented tide change. There is no scope creep.

Issues Encountered

  • The shell aliases cp and rm to their interactive forms. A loop that held back fixtures between task commits consumed stdin through the prompts. Nothing was lost: every file was re-copied, and the later loops use command cp -f/command rm -f.
  • One full-suite run hit a testcontainers start timeout in the updates package under parallel load. The package passes on its own and in later full runs.
  • The recording wrote covers into the PHP checkout's storage/app/uploads/public/6ac/. That directory was removed afterwards. The recording also reset the isolated parity SQLite only.

Verification

  • fonoteka.go: go vet ./... passes. go test ./... -count=1 and go test ./plugins/golem15/fonoteka/... -count=1 pass in every package.
  • Each task's <verify> command ran with -race and showed --- PASS for every named test: TestDiscogsMatchRoute, TestDiscogsInboundLimits, TestApplyReleaseModes, TestDiscogsImportRoute, TestCoverPriceRoute, TestDiscogsCredentialTestRoute, TestCoverFetcherHostLock, TestRouteTablePhase12/13/14. There was no SKIP and no DATA RACE.
  • Parity: TestParityCorpus/coverage (165 ported and passing, 6 pending), TestCheckCorpusPortedCaseStatus, TestUpstreamSidecarsAreReplayed and TestParityContract pass. check_corpus --routes … --require-recorded --check-secrets reports 171/171 recorded. Its one pending case-status mismatch is albums/recognize (14-04).
  • A mutation check (an extra filled tag) made the apply-release corpus case fail, so the new cases do compare bodies.
  • summercms.go: go vet ./... and go test ./modules/tide ./cmd/summer -run TestDocsTree pass, and summer docs:build --check reports no problems.
  • Acceptance greps: expectedPortedRoutes is 165. cover-price/discogs appears once in routes.go, on the line carrying inv.scope:write and throttle:12,1. phase14Absent has no discogs-credential/test, and fonoteka-discogs-missing: appears once in inbound_limits.go.

Known Stubs

None.

Threat Flags

None. The new surface (the eight routes, the cover downloads and the token test) is in the plan's threat register. T-14-16..T-14-21 are covered by the tests named above.

Next Phase Readiness

  • 14-04 can port ai-credential/test and recognize on the same recording setup: the per-case proxy loop, the discogs extra pattern and binary sidecar bodies.
  • 14-06 picks up deferred-items.md: moving the Phase 12 cover imports onto the guarded importer, with re-recorded cover sidecars.

Phase: 14-domain-jobs-and-external-integrations Completed: 2026-10-03

Self-Check: PASSED

Every created file exists. Commits d8cd725, 6a995a6 and 2834943 (fonoteka.go) and 5101ecb (summercms.go) are present, and the 81 route fixtures and sidecars are on disk.