docs(14): create phase plan

This commit is contained in:
Jakub Zych
2026-10-03 18:56:43 +02:00
parent de5b01266b
commit ed4d4c380c
11 changed files with 1989 additions and 21 deletions

View File

@@ -747,7 +747,27 @@ Plans:
5. AI cover recognition works through both Anthropic and OpenAI-compatible adapters with per-credential model/base-URL overrides; `ai-credential/test` passes the parity diff, and the AI resolver's admin tier uses the backend global vision model. 5. AI cover recognition works through both Anthropic and OpenAI-compatible adapters with per-credential model/base-URL overrides; `ai-credential/test` passes the parity diff, and the AI resolver's admin tier uses the backend global vision model.
6. Feedback submissions and sitemap output work; the ported `oauth-client`/`prune-notifications`/`reindex` commands all run correctly. 6. Feedback submissions and sitemap output work; the ported `oauth-client`/`prune-notifications`/`reindex` commands all run correctly.
**Plans**: TBD **Plans:** 6 plans
Plans:
**Wave 1**
- [ ] 14-01-PLAN.md — Framework (summercms.go): fetchguard guarded client with TrustedMode and a code-only transport seam, tide upstream sidecars with an asserting replay fake and the `summer parity:upstream` recording proxy, the `sunscreen` redacting slog handler in generated mains, beachcomber IndexDropper/EnsureIndex; READMEs and docs; REQUIREMENTS/ROADMAP/PROJECT rewording (D-06, D-07, D-13, D-14)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 14-02-PLAN.md — Discogs client with the Postgres UNLOGGED limiter and domain classes, real ReleaseFetcher, WR-02 locks, CSV match/import workers with write-service CSV variants, digest worker and mail, prune-notifications and reindex commands, row-edit pick parity cases
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 14-03-PLAN.md — Discogs routes: album and wishlist match/apply-release, albums/match, albums/import/discogs, token cover-price (AlbumCoverFetcher, host-locked), discogs-credential/test; inbound limiters; recordings with sidecars (165 ported)
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 14-04-PLAN.md — sm-golem-plugin (models table, admin, importer, Anthropic/OpenAI-compatible adapters, AIService, SSRF guard) mounted as a submodule; admin vision tier; AlbumRecognitionService, recognize on both groups and ai-credential/test (168 ported)
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 14-05-PLAN.md — sm-feedback-plugin: config, submit, preflight, me/hidden, getApiArray hook, embed.js, settings and submissions admin, importer, G15Office River job; feedback parity section (175 routes, 172 ported)
**Wave 6** *(blocked on Wave 5 completion)*
- [ ] 14-06-PLAN.md — Unit tests last: PHP Discogs truth tables, full coverage, threat and edge tests, route table and fuzz, scripts/check-phase14.sh, security review, validation and requirement evidence
### Phase 15: Cutover ### Phase 15: Cutover
@@ -788,7 +808,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| | | 11.2. summercms.io Alpha 0.1 landing page on SummerCMS | 3/3 | In Progress| |
| 12. Płytarium API — Collections and Albums | 5/5 | Complete | 2026-10-02 | | 12. Płytarium API — Collections and Albums | 5/5 | Complete | 2026-10-02 |
| 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | In Progress| | | 13. Płytarium API — wishlist, notifications, CSV, credentials, public routes | 6/6 | In Progress| |
| 14. Domain jobs and external integrations | 0/TBD | Not started | - | | 14. Domain jobs and external integrations | 0/6 | Planned | - |
| 15. Cutover | 0/TBD | Not started | - | | 15. Cutover | 0/TBD | Not started | - |
## Backlog ## Backlog

View File

@@ -1,18 +1,18 @@
--- ---
gsd_state_version: "1.0" gsd_state_version: "1.0"
milestone: v1.0 milestone: v1.0
current_phase: 13 current_phase: 14
current_phase_name: Płytarium API — wishlist, notifications, CSV, credentials, public routes current_phase_name: domain-jobs-and-external-integrations
status: verifying status: executing
stopped_at: Phase 14 context gathered stopped_at: Phase 14 context gathered
last_updated: "2026-10-03T15:27:13.612Z" last_updated: "2026-10-03T16:56:39.498Z"
last_activity: 2026-10-03 last_activity: 2026-10-03
last_activity_desc: Phase 13 execution started last_activity_desc: Phase 13 execution started
state_head: 3c9516b08016a9b30234368f2de0b49d886d001a state_head: de5b01266b4919206905dd8174e7e50d4546726a
progress: progress:
total_phases: 21 total_phases: 21
completed_phases: 11 completed_phases: 11
total_plans: 112 total_plans: 118
completed_plans: 112 completed_plans: 112
milestone_name: milestone milestone_name: milestone
--- ---
@@ -28,9 +28,9 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position ## Current Position
Phase: 13 (Płytarium API — wishlist, notifications, CSV, credentials, public routes) — EXECUTING Phase: 14 (domain-jobs-and-external-integrations) — READY TO EXECUTE
Plan: 6 of 6 Plan: 6 of 6
Status: Phase complete — ready for verification Status: Ready to execute
Last activity: 2026-10-03 — Phase 13 execution started Last activity: 2026-10-03 — Phase 13 execution started
Progress: [██████░░░░] 60% Progress: [██████░░░░] 60%

View File

@@ -0,0 +1,339 @@
---
phase: 14-domain-jobs-and-external-integrations
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/fetchguard/client.go
- modules/fetchguard/client_test.go
- modules/fetchguard/fetch.go
- modules/fetchguard/policy.go
- modules/fetchguard/example_test.go
- modules/fetchguard/README.md
- modules/tide/upstream.go
- modules/tide/upstream_test.go
- modules/tide/upstream_proxy.go
- modules/tide/upstream_proxy_test.go
- modules/tide/testdata/upstream/
- modules/tide/README.md
- modules/sunscreen/sunscreen.go
- modules/sunscreen/sunscreen_test.go
- modules/sunscreen/example_test.go
- modules/sunscreen/README.md
- modules/surf/recover_redaction_test.go
- modules/beachcomber/searchable.go
- modules/beachcomber/searchpage_test.go
- modules/beachcomber/typesense/engine.go
- modules/beachcomber/typesense/engine_test.go
- modules/beachcomber/README.md
- internal/build/build.go
- internal/build/build_test.go
- examples/hello/main.go
- cmd/summer/parity.go
- cmd/summer/main.go
- cmd/summer/main_test.go
- cmd/summer/parity_contract_test.go
- README.md
- docs/services/outbound-http.md
- docs/services/logging.md
- docs/services/parity-testing.md
- docs/services/search.md
- docs/console/utilities.md
- docs/setup/coming-from-wintercms.md
- docs/architecture/introduction.md
- ../fonoteka.go/main.go
- .planning/REQUIREMENTS.md
- .planning/ROADMAP.md
- .planning/PROJECT.md
autonomous: true
requirements: [INTG-01, INTG-02, API-08, SRCH-02]
estimate:
tokens: 350000
raw_tokens: 350000
tasks: 5
confidence: low
must_haves:
truths:
- "Per D-02 and the folded fetchguard todo, `fetchguard.NewClient(policy, cfg)` returns a guarded outbound client whose `Do`, `Send`, `Get`, `PostJSON`, `PutJSON` and `PostMultipart` send any method with JSON or multipart bodies and bearer headers, cap the response at MaxBytes (`too_large`), return the status and headers without judging them, and never follow a redirect in any mode (a 3xx comes back as a Result)."
- "Per D-05, `fetchguard.TrustedMode` is declared after `PublicOnlyMode` (existing numeric values unchanged); only TrustedMode accepts http and skips the host check and the dial-time private/reserved-IP guard; AllowHostsMode and PublicOnlyMode stay https-only with the dial guard on every new connection."
- "The transport override is code-only: it rides an unexported context key set by `fetchguard.WithTransport(ctx, rt)`; no Policy field, Client field, compass key, environment variable or request header can set it, and a reflection test proves Policy and Client export no http.RoundTripper."
- "Per D-15, `tide.LoadUpstream` reads `<fixture>.upstream.yaml` sidecars and `tide.NewUpstreamFake(sidecar, store)` answers the recorded responses offline while asserting each request Go sends (method, scheme, host, path, query, the compared headers with `{{name}}` placeholders expanded from the store, and the body compared as JSON semantically or by sha256 for base64 payloads); `Verify` fails on any mismatch, unconsumed exchange or extra request."
- "Per D-19, `summer parity:upstream` runs a loopback-only recording HTTPS proxy with a locally generated CA (key mode 0600, outside the fixtures tree), answers from a script file in script mode or forwards once in forward mode, and writes the sidecar with every secret replaced by a `{{name}}` var; an Authorization or X-Api-Key value that no var masks refuses the write."
- "Per the folded redacting-slog-handler todo, `sunscreen.Wrap` redacts the RedactCredentialsTap keys (api_key, apikey, authorization, bearer, password, secret, token, webhook_secret, admin_password, openai_api_key, anthropic_api_key, perplexity_api_key) case-insensitively in attrs, WithAttrs and nested groups, and scrubs Bearer, `sk-` and `x-api-key:` shapes from messages and string values; every generated application main installs it first through `sunscreen.InstallDefault(os.Stderr)`, and surf's recovered 500 echoes no panic text (SafeExceptionResponse behaviour pinned by a test)."
- "Per research Open Question 6 (resolved: add it), `beachcomber.DropIndex(ctx, engine, index)` reports whether the index existed through the optional `IndexDropper`, and the Typesense engine answers existed=false on a 404, so reindex can keep PHP's distinct already-absent message; `beachcomber.EnsureIndex` creates an empty index from its schema, as PHP's reindex does for an empty album table."
- "Per D-06, D-07, D-13 and D-14, REQUIREMENTS INTG-02 names Anthropic and OpenAI-compatible adapters over the guarded client, API-08 is feedback only, ROADMAP Phase 14 criteria 4-6 list the album Discogs and recognize routes and drop sitemap, its Repos line names summercms.go, sm-golem-plugin and sm-feedback-plugin, and PROJECT.md moves golem and feedback to their sm-*-plugin repos and drops feedback and sitemap from the application-plugin list, in a planning-docs-only commit."
- "Every changed module README and the affected docs pages name only identifiers that exist: `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` pass, and no framework README or docs page names a consuming application."
artifacts:
- path: "modules/fetchguard/client.go"
provides: "NewClient, Client, Do, Send, Get, PostJSON, PutJSON, PostMultipart, FormField, FormFile, Bearer, WithTransport"
contains: "func WithTransport("
- path: "modules/tide/upstream.go"
provides: "UpstreamSidecar, UpstreamExchange, UpstreamRequest, UpstreamResponse, UpstreamPath, LoadUpstream, WriteUpstream, UpstreamFake, NewUpstreamFake, UpstreamCompareHeaders"
contains: "func NewUpstreamFake("
- path: "modules/tide/upstream_proxy.go"
provides: "UpstreamProxyConfig, UpstreamProxy, NewUpstreamProxy, EnsureParityCA, UpstreamScript, DefaultUpstreamProxyListen"
contains: "DefaultUpstreamProxyListen"
- path: "modules/sunscreen/sunscreen.go"
provides: "Wrap, InstallDefault, Scrub, Redacted, RedactedKeys"
contains: "func InstallDefault("
- path: "modules/beachcomber/searchable.go"
provides: "IndexDropper, DropIndex, IndexEnsurer, EnsureIndex"
contains: "IndexDropper"
- path: "cmd/summer/parity.go"
provides: "parity:upstream command"
contains: "parity:upstream"
key_links:
- from: "modules/tide/upstream.go"
to: "modules/fetchguard/client.go"
via: "UpstreamFake is the http.RoundTripper handed to fetchguard.WithTransport during replay"
pattern: "RoundTrip"
- from: "internal/build/build.go"
to: "modules/sunscreen/sunscreen.go"
via: "generated main calls sunscreen.InstallDefault(os.Stderr) before loading config"
pattern: "sunscreen.InstallDefault"
- from: "cmd/summer/parity.go"
to: "modules/tide/upstream_proxy.go"
via: "parity:upstream builds an UpstreamProxy and flushes the sidecar on shutdown"
pattern: "NewUpstreamProxy"
prohibitions:
- requirement_id: INTG-02
category: safety
statement: "The trusted (unguarded) outbound mode MUST NOT be selectable by configuration, request input or environment; only Go code that builds the policy can choose it"
status: resolved
verification: test
- requirement_id: INTG-01
category: privacy
statement: "A recorded upstream sidecar MUST NOT contain a live credential; an unmasked Authorization or X-Api-Key value refuses the write"
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: the framework gains what every later plan of the phase stands on. An application can call a vendor API through one guarded client, replay that call offline against what PHP really sent, record PHP's real upstream exchanges, keep credentials out of logs, and tell a dropped search index from an absent one. The planning docs say what Phase 14 now ships.
<objective>
Extend fetchguard into a guarded outbound client with a trusted mode and a code-only transport seam, add tide upstream sidecars with an asserting replay fake and the `summer parity:upstream` recording proxy, add the `sunscreen` redacting slog handler installed by every generated main, add `beachcomber.IndexDropper`, update module READMEs and docs, and reword REQUIREMENTS, ROADMAP and PROJECT per D-06, D-07, D-13 and D-14.
Purpose: Discogs, the Golem AI adapters and the G15Office job (plans 14-02 to 14-05) all send credentials to outside services through this client and are tested through these sidecars (D-02, D-05, D-15, D-19).
Output: framework packages with tests, READMEs and docs; regenerated application mains; reworded planning docs.
Repos: summercms.go (code and planning docs, separate commits) and fonoteka.go (only the regenerated `main.go`, its own commit). Commits are 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/PROJECT.md
@.planning/STATE.md
@.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-PATTERNS.md
@modules/fetchguard/fetch.go
@modules/fetchguard/policy.go
<interfaces>
- fetchguard today: `Fetch(ctx, rawURL, Policy, *compass.Config) (*Result, error)` GET only; `Policy{Mode, AllowHosts, MaxBytes, Timeout}` plus unexported `tlsConfig`, `skipReservedCheck`; `Mode` constants `AllowHostsMode`, `PublicOnlyMode`; `Result{Body, ContentType, StatusCode}`; `*Error{Reason, Err}` with reasons invalid_url, scheme, unresolvable, private_ip, network_error, too_large; helpers `resolveLimits`, `dialControl`, `mapTransportError`, `hostAllowed`, `isReservedOrPrivate` (ip.go).
- tide: `Store` and `OpenStore(path)` (variables.go, `{{name}}` substitution and reverse masking), `CentrifugoRecorder` (centrifugo.go, the fake-recorder pattern: options, mutex slice, LimitReader, never stores the auth secret), `Proxy`/`ProxyConfig`/`NewProxy` and `requireLoopbackAddr` (proxy.go), `DefaultCentrifugoListen = "127.0.0.1:8424"`, flows decoded with goccy/go-yaml DisallowUnknownField (fixture.go).
- cmd/summer: `toolCommands()` (main.go), `parityBroadcastsCommand()` and `requireFlag`, `varsOutsideDir` (parity.go), `TestToolCommandNames` expected list (main_test.go), `TestParityCommandContract` (parity_contract_test.go).
- internal/build: `generateMain(m Manifest)` writes the application main; `TestDocsCommandsMirrorGeneratedMain` only parses `commands :=` / `append(commands,` constructors.
- beachcomber: `Engine{Name, Configured, Upsert, Delete, Flush, SearchIDs}`, optional `PageSearcher` plus `SearchPage(ctx, e, index, q)` fallback (searchable.go:87-108); typesense `(*Engine).Flush` treats 404 as success (typesense/engine.go:205-215).
- surf: `recoverJSON`/`recoverBare` (router.go), `TestRecoverReturnsOpaqueJSON500` (router_test.go).
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- fetchguard: `NewClient`, `Client`, `(*Client).Do`, `Send`, `Get`, `PostJSON`, `PutJSON`, `PostMultipart`, `FormField{Name, Value}`, `FormFile{Field, Filename, ContentType, Body}`, `Bearer`, `WithTransport`, `TrustedMode`, `Result.Header`.
- tide: `UpstreamSidecar`, `UpstreamExchange`, `UpstreamRequest`, `UpstreamResponse`, `UpstreamPath`, `LoadUpstream`, `WriteUpstream`, `UpstreamFake`, `NewUpstreamFake`, `(*UpstreamFake).RoundTrip`, `(*UpstreamFake).Verify`, `UpstreamCompareHeaders`, `UpstreamScript`, `UpstreamScriptResponse`, `UpstreamProxyConfig`, `UpstreamProxy`, `NewUpstreamProxy`, `(*UpstreamProxy).ListenAndServe`, `(*UpstreamProxy).Flush`, `EnsureParityCA`, `DefaultUpstreamProxyListen` (`127.0.0.1:8425`). Sidecar file format `<fixture>.upstream.yaml` (version 1).
- New module `modules/sunscreen` (import `git.golem15.com/golem15/summercms/modules/sunscreen`): `Wrap`, `InstallDefault`, `Scrub`, `Redacted`, `RedactedKeys`; README; root README modules row.
- beachcomber: `IndexDropper`, `DropIndex`, `IndexEnsurer`, `EnsureIndex`; typesense `(*Engine).DropIndex`, `(*Engine).EnsureIndex`.
- CLI: `summer parity:upstream` (`--listen`, `--ca-dir`, `--out`, `--mode`, `--script`, `--vars`).
- Docs: new `docs/services/logging.md`; updated outbound-http, parity-testing, search, console utilities, coming-from-wintercms, architecture introduction.
- Tests: `TestClientPostJSONThroughUpstreamFake`, `TestUpstreamFakeRejectsMismatchedRequest`, `TestTransportSeamIsCodeOnly`, `TestClientModes`, `TestClientNeverFollowsRedirects`, `TestClientMultipart`, `TestClientBodyCap`, `TestClientSchemeGuard`, `TestUpstreamProxyScriptMode`, `TestUpstreamProxyRefusesNonLoopback`, `TestWriteUpstreamRefusesUnmaskedCredential`, `TestUpstreamFakeHashesBase64Bodies`, `TestEnsureParityCA`, `TestRedactHandler`, `TestScrub`, `TestGenerateMainInstallsRedactingLogger`, `TestRecoverHidesPanicDetails`, `TestDropIndex`, `TestEngineDropIndex`, `TestEngineEnsureIndex`.
## Assumptions
- The phrase "unexported test-transport seam" in the confirmed plan split is implemented as an unexported context key type and unexported Client state, set only through the code-level `WithTransport`; nothing reachable from production input can set it.
- Sidecars carry no step index: exchanges are consumed in recorded order across the whole flow.
- A4 (research): consumers choose their timeouts (Discogs 10 s, AI 120 s, G15Office 30 s); the client has no vendor defaults.
<tasks>
<task type="tracer">
<name>Task 1: A guarded JSON POST leaves through fetchguard.Client and a tide upstream fake answers it offline while asserting the exact request</name>
<files>modules/fetchguard/client.go, modules/fetchguard/client_test.go, modules/fetchguard/fetch.go, modules/fetchguard/policy.go, modules/tide/upstream.go, modules/tide/upstream_test.go, modules/tide/testdata/upstream/</files>
<read_first>modules/fetchguard/fetch.go (Fetch, resolveLimits, dialControl, mapTransportError, the body cap at lines 85-96), modules/fetchguard/policy.go, modules/fetchguard/fetch_test.go (withTestLoopback), modules/tide/centrifugo.go (CentrifugoRecorder: options, mutex, LimitReader, secret never stored), modules/tide/variables.go (Store, placeholder expansion and reverse masking), modules/tide/fixture.go (goccy decode with DisallowUnknownField), modules/tide/diff.go (structural JSON compare with UseNumber), .planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md (sections "fetchguard: what the guarded client needs" and "How sidecars fit the existing layout")</read_first>
<action>Per D-02, D-05 and D-15 (thinnest path through both packages).
(1) fetchguard/client.go: `NewClient(policy Policy, cfg *compass.Config) (*Client, error)` resolves MaxBytes and Timeout once through resolveLimits and builds one http.Client: CheckRedirect returns http.ErrUseLastResponse, Transport with Proxy nil, the dial Control from dialControl(policy), TLSClientConfig from policy.tlsConfig, keep-alives on (the dial Control runs on every new connection). `(*Client).Do(req *http.Request) (*http.Response, error)` validates the URL exactly as Fetch does (invalid_url, https-only, AllowHosts in AllowHostsMode), then sends through the RoundTripper stored in the request context by `WithTransport` when present, otherwise through its own transport, and wraps the response body in a reader that fails with `*Error{Reason: ReasonTooLarge}` past MaxBytes. `(*Client).Send(req) (*Result, error)` reads the whole capped body. `(*Client).PostJSON(ctx, rawURL string, header http.Header, body any) (*Result, error)` marshals body, sets `Content-Type: application/json` first and then copies header (the RequestSender order). Result gains `Header http.Header` (additive field; Fetch fills it too). `WithTransport(ctx context.Context, rt http.RoundTripper) context.Context` stores rt under an unexported key type; its doc comment states it is the test and parity-replay seam and that no Policy field, config key, environment variable or header can set it. Refactor Fetch to share the transport builder without changing its behaviour or tests.
(2) tide/upstream.go: types `UpstreamSidecar{Version int; Exchanges []UpstreamExchange}`, `UpstreamExchange{Request UpstreamRequest; Response UpstreamResponse}`, `UpstreamRequest{Method, URL string; Headers map[string]string; Body string}`, `UpstreamResponse{Status int; Headers map[string]string; Body string}` with yaml tags in lower snake case; `UpstreamPath(fixturePath string) string` maps `x.yaml` to `x.upstream.yaml`; `LoadUpstream(path string) (UpstreamSidecar, error)` decodes with goccy/go-yaml DisallowUnknownField, requires version 1 and wraps fs.ErrNotExist when the file is absent. `UpstreamCompareHeaders` lists User-Agent, Accept, Content-Type, Authorization, X-Api-Key, Anthropic-Version, Anthropic-Beta. `NewUpstreamFake(s UpstreamSidecar, store *Store) *UpstreamFake` implements http.RoundTripper: it takes the next exchange in order, compares method, scheme, host and path, the parsed query (order-insensitive), each compared header present on either side after expanding `{{name}}` placeholders from store, and the body (JSON semantically with UseNumber when either Content-Type is JSON, otherwise bytes); a match returns the recorded status, headers and body without dialing; a mismatch returns an error naming the field and is remembered. `(*UpstreamFake).Verify() error` joins every mismatch, each unconsumed exchange and each extra request. Neutral hosts only (api.example.test).
(3) Tests: client_test.go `TestClientPostJSONThroughUpstreamFake` (load testdata/upstream/post_json.upstream.yaml, AllowHostsMode client for api.example.test, ctx from WithTransport with the fake, PostJSON returns the sidecar status, header and body, Verify is nil) and `TestTransportSeamIsCodeOnly` (reflection over Policy and Client finds no exported field of a type implementing http.RoundTripper; a request without the context override reaches the real transport, proven by an AllowHostsMode call to a host outside the list failing before any dial); upstream_test.go `TestUpstreamFakeRejectsMismatchedRequest` (wrong method, path, query value, User-Agent, Authorization after expansion, JSON body value, an extra request and an unconsumed exchange each make Verify fail with the field named).</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/fetchguard ./modules/tide -count=1 -v -run '^(TestClientPostJSONThroughUpstreamFake|TestTransportSeamIsCodeOnly|TestUpstreamFakeRejectsMismatchedRequest)$' &amp;&amp; go test ./modules/fetchguard ./modules/tide -count=1</automated>
<fails_when>Any command exits non-zero; the verbose run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS" for TestClientPostJSONThroughUpstreamFake, TestTransportSeamIsCodeOnly and TestUpstreamFakeRejectsMismatchedRequest; the package runs report FAIL for an existing fetchguard or tide test.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'func WithTransport(' modules/fetchguard/client.go` prints 1.
- `grep -c 'func NewUpstreamFake(' modules/tide/upstream.go` prints 1 and `grep -c 'func (f \*UpstreamFake) Verify() error' modules/tide/upstream.go` prints 1.
- `grep -cE 'ErrUseLastResponse' modules/fetchguard/client.go` prints at least 1.
- `ls modules/tide/testdata/upstream/post_json.upstream.yaml` succeeds and `grep -c 'version: 1' modules/tide/testdata/upstream/post_json.upstream.yaml` prints 1.
</acceptance_criteria>
<done>A JSON POST built by the guarded client is answered offline from a sidecar and its request is asserted field by field, proving the D-15 replay architecture before any vendor code exists.</done>
</task>
<task type="auto">
<name>Task 2: An application calls any vendor API through the guarded client (PUT, multipart, bearer, trusted operator endpoints) and the docs show how</name>
<files>modules/fetchguard/client.go, modules/fetchguard/client_test.go, modules/fetchguard/policy.go, modules/fetchguard/example_test.go, modules/fetchguard/README.md, README.md, docs/services/outbound-http.md, docs/setup/coming-from-wintercms.md, docs/architecture/introduction.md</files>
<read_first>modules/fetchguard/client.go (Task 1), modules/fetchguard/policy.go (Mode block lines 12-18), modules/fetchguard/ip.go, modules/fetchguard/README.md, modules/fetchguard/example_test.go, README.md (modules table), docs/services/outbound-http.md, docs/setup/coming-from-wintercms.md (line 41 row), docs/architecture/introduction.md (line 28), /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/RequestSender.php (sendPostRequest, header order, multipart), CLAUDE.md "Documentation" section</read_first>
<action>Per D-02 and D-05.
(1) policy.go: add `TrustedMode` after `PublicOnlyMode` in the Mode const block so existing values keep 0 and 1. Its doc comment: for endpoints an operator configured in Go code or admin settings (for example a LAN model server); http and https allowed, no host check, no dial guard; still capped, still no redirects. client.go: in TrustedMode skip the scheme rule, the AllowHosts check and dialControl; in the other modes keep them. Add `PutJSON(ctx, rawURL, header, body)`, `Get(ctx, rawURL, header)`, `PostMultipart(ctx, rawURL string, header http.Header, fields []FormField, files []FormFile) (*Result, error)` with `FormField{Name, Value string}` and `FormFile{Field, Filename, ContentType string; Body io.Reader}` written in slice order through mime/multipart, and `Bearer(token string) string` returning `Bearer <token>`. Request bodies are not capped (callers bound their own inputs).
(2) Tests in client_test.go: `TestClientModes` (AllowHostsMode refuses a host outside the list with invalid_url; PublicOnlyMode refuses a loopback address at dial with private_ip; TrustedMode reaches an http httptest server on 127.0.0.1), `TestClientNeverFollowsRedirects` (a 302 is returned as a Result in all three modes and the Location target is never requested), `TestClientMultipart` (field and file order, filename, part Content-Type and bytes as received by an httptest server), `TestClientBodyCap` (MaxBytes plus one byte fails Send with too_large; Do's body reader fails the same way), `TestClientSchemeGuard` (http refused with scheme in both guarded modes). example_test.go: `ExampleClient_PostJSON` using WithTransport with a stub RoundTripper, no network.
(3) Docs in the same commit: fetchguard README (summary sentence now "Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints."; Features, Usage with Client, API reference for every new identifier, Testing names WithTransport); root README modules row reuses that sentence; docs/services/outbound-http.md gains "Calling a service API" (client per vendor, JSON, multipart, bearer, per-consumer timeouts, trusted mode only for operator endpoints, never for user-supplied URLs) and "Testing outbound calls" (WithTransport plus a link to parity-testing.md); coming-from-wintercms.md gains a row mapping a plugin's own curl request sender to `fetchguard.Client`; architecture/introduction.md keeps fetchguard in the HTTP row with the new wording. Never name a consuming application.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/fetchguard -count=1 -race -v -run '^(TestClientModes|TestClientNeverFollowsRedirects|TestClientMultipart|TestClientBodyCap|TestClientSchemeGuard|ExampleClient_PostJSON)$' &amp;&amp; go test ./modules/fetchguard -count=1 &amp;&amp; go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Any command exits non-zero; the verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for any of the five client tests or ExampleClient_PostJSON; docs:build --check reports an unknown identifier, a broken link or a consuming-application name.</fails_when>
</verify>
<acceptance_criteria>
- `grep -nE '^\s+TrustedMode$' modules/fetchguard/policy.go` shows a line after the `PublicOnlyMode` line.
- `grep -c 'func (c \*Client) PostMultipart(' modules/fetchguard/client.go` prints 1 and `grep -c 'func Bearer(' modules/fetchguard/client.go` prints 1.
- `grep -c 'TrustedMode' modules/fetchguard/README.md` and `grep -c 'WithTransport' docs/services/outbound-http.md` each print at least 1.
- The fetchguard row of README.md contains "trusted mode for operator-configured endpoints".
</acceptance_criteria>
<done>The guarded client covers every request shape the Discogs, Golem and G15Office ports need, with the trusted mode reachable only from code, and the README and docs describe it.</done>
</task>
<task type="auto">
<name>Task 3: A developer records what the reference backend really sends upstream with summer parity:upstream and replays it offline</name>
<files>modules/tide/upstream.go, modules/tide/upstream_test.go, modules/tide/upstream_proxy.go, modules/tide/upstream_proxy_test.go, modules/tide/testdata/upstream/, modules/tide/README.md, cmd/summer/parity.go, cmd/summer/main.go, cmd/summer/main_test.go, cmd/summer/parity_contract_test.go, docs/console/utilities.md, docs/services/parity-testing.md</files>
<read_first>modules/tide/proxy.go (ProxyConfig, NewProxy, requireLoopbackAddr, varsOutsideFixtures, sessions), modules/tide/variables.go (Store, reverse masking, unclassified credential refusal), modules/tide/rules.go, modules/tide/upstream.go (Task 1), cmd/summer/parity.go (parityBroadcastsCommand, runParityBroadcasts, requireFlag, varsOutsideDir), cmd/summer/main.go (toolCommands), cmd/summer/main_test.go (TestToolCommandNames), cmd/summer/parity_contract_test.go, docs/console/utilities.md (parity table), docs/services/parity-testing.md, modules/tide/README.md, .planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md ("Recording PHP's side" paragraph and assumption A1)</read_first>
<action>Per D-15 and D-19.
(1) upstream.go: `WriteUpstream(path string, s UpstreamSidecar, store *Store) error` replaces every header value and body substring equal to a store variable value with `{{name}}`, refuses (error naming the header) an Authorization or X-Api-Key value that is not fully masked, replaces any JSON string value longer than 1024 characters that decodes as base64 with `{{sha256:<hex of the decoded bytes>}}`, and writes mode 0644. The fake compares such a placeholder by hashing the decoded value Go sent. Multipart bodies are stored as an ordered list of parts (name, filename, content type, sha256 of the bytes) and compared that way.
(2) upstream_proxy.go: `DefaultUpstreamProxyListen = "127.0.0.1:8425"`; `UpstreamProxyConfig{Listen, CADir, Out, Mode, Script, VarsPath string}`; `NewUpstreamProxy(cfg) (*UpstreamProxy, error)` requires a loopback Listen (requireLoopbackAddr), Mode `script` (default) or `forward`, Script in script mode, Out, and a CADir and VarsPath outside the directory that holds Out; `EnsureParityCA(dir string) (certPath string, err error)` creates or reuses an ECDSA P-256 self-signed CA (`parity-ca.pem` 0644, `parity-ca-key.pem` 0600) with crypto/x509 only; the proxy answers CONNECT, terminates TLS with a per-host leaf certificate signed by that CA, reads the decrypted request (body capped), and in script mode answers from `UpstreamScript{Responses []UpstreamScriptResponse{Method, Host, Path string; Response UpstreamResponse}}` (first unused entry whose method, host and path match; no match answers 599 and is recorded as an error), in forward mode sends the request once through a `fetchguard.Client` in PublicOnlyMode and returns the real response; every exchange is appended under a mutex; `ListenAndServe(ctx)` stops on ctx cancel; `Flush() error` writes Out with WriteUpstream.
(3) CLI: cmd/summer/parity.go `parityUpstreamCommand()` named `parity:upstream` with flags `--listen` (default tide.DefaultUpstreamProxyListen), `--ca-dir`, `--out`, `--mode` (default script), `--script`, `--vars`, using requireFlag; it prints the CA certificate path for the reference backend's curl and openssl CA settings, serves until interrupted, then flushes. Register it in toolCommands, add it to TestToolCommandNames' list with its flags, and add a contract case (non-loopback listen refused, vars inside the output directory refused) to TestParityCommandContract.
(4) Tests: upstream_proxy_test.go `TestUpstreamProxyScriptMode` (a Go client with Proxy set to the proxy URL and RootCAs from EnsureParityCA posts JSON with an Authorization header to https://api.example.test/v1/things, receives the scripted response, Flush writes a sidecar whose Authorization is `{{secret:example-token}}` and that LoadUpstream reads back), `TestUpstreamProxyRefusesNonLoopback`, `TestEnsureParityCA` (key mode 0600, second call reuses the files); upstream_test.go `TestWriteUpstreamRefusesUnmaskedCredential`, `TestUpstreamFakeHashesBase64Bodies` (a 2 KiB base64 image in a JSON body is stored as a sha256 placeholder and the fake accepts the same bytes and rejects one changed byte).
(5) Docs in the same commit: modules/tide/README.md (Features, Usage for sidecars, fake and proxy, API reference for every new identifier, CLI commands row, security rules: loopback only, 0600 CA key outside fixtures, masked credentials, forward mode only by explicit flag and never in CI); docs/console/utilities.md table row for `summer parity:upstream` with flags; docs/services/parity-testing.md section "Upstream exchanges" (sidecar format, recording through the proxy with the reference backend's HTTPS proxy and CA settings, offline replay through WithTransport). Neutral names only.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/tide -count=1 -race -v -run '^(TestUpstreamProxyScriptMode|TestUpstreamProxyRefusesNonLoopback|TestEnsureParityCA|TestWriteUpstreamRefusesUnmaskedCredential|TestUpstreamFakeHashesBase64Bodies|TestUpstreamFakeRejectsMismatchedRequest)$' &amp;&amp; go test ./modules/tide -count=1 &amp;&amp; go test ./cmd/summer -count=1 -v -run '^(TestToolCommandNames|TestParityCommandContract|TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' &amp;&amp; go run ./cmd/summer docs:build --check</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 TestUpstreamProxyScriptMode, TestEnsureParityCA, TestWriteUpstreamRefusesUnmaskedCredential, TestUpstreamFakeHashesBase64Bodies and TestToolCommandNames; docs:build --check reports an unknown identifier or command.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '"parity:upstream"' cmd/summer/parity.go` prints at least 1 and `grep -c 'parityUpstreamCommand()' cmd/summer/main.go` prints 1.
- `grep -c 'parity:upstream' docs/console/utilities.md` prints at least 1 and `grep -c 'upstream.yaml' docs/services/parity-testing.md` prints at least 1.
- `grep -c '127.0.0.1:8425' modules/tide/upstream_proxy.go` prints at least 1.
- `grep -rc 'golang.org/x/' modules/tide/upstream_proxy.go` prints 0 (stdlib crypto only).
</acceptance_criteria>
<done>PHP's real outbound requests can be captured through a loopback MITM proxy into masked sidecars and replayed offline, with the command documented.</done>
</task>
<task type="auto">
<name>Task 4: Operators never see a credential in application logs, and a reindex can tell a dropped search index from an absent one</name>
<files>modules/sunscreen/sunscreen.go, modules/sunscreen/sunscreen_test.go, modules/sunscreen/example_test.go, modules/sunscreen/README.md, modules/surf/recover_redaction_test.go, internal/build/build.go, internal/build/build_test.go, examples/hello/main.go, modules/beachcomber/searchable.go, modules/beachcomber/searchpage_test.go, modules/beachcomber/typesense/engine.go, modules/beachcomber/typesense/engine_test.go, modules/beachcomber/README.md, README.md, docs/services/logging.md, docs/services/search.md, ../fonoteka.go/main.go</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/logging/RedactCredentialsTap.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/traits/SafeExceptionResponse.php, .planning/todos/pending/redacting-slog-handler.md, .planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md ("Redacting slog handler" and its code example), internal/build/build.go (generateMain lines 80-133), internal/build/build_test.go (TestGenerateMainRegistersCongaRuntimeCommands), modules/surf/router.go (recoverJSON, recoverBare), modules/surf/router_test.go (TestRecoverReturnsOpaqueJSON500), modules/beachcomber/searchable.go (PageSearcher, SearchPage), modules/beachcomber/typesense/engine.go (Flush, do, statusError), modules/beachcomber/README.md, docs/services/search.md, README.md (modules table format), CLAUDE.md "Documentation" section</read_first>
<action>Per the folded redacting-slog-handler todo and research Open Question 6.
(1) New module modules/sunscreen (package sunscreen): `Redacted = "[REDACTED]"`; `RedactedKeys` holds api_key, apikey, authorization, bearer, password, secret, token, webhook_secret, admin_password, openai_api_key, anthropic_api_key, perplexity_api_key (compared case-insensitively); `Scrub(s string) string` applies the PHP patterns: `Bearer` plus token becomes `Bearer [REDACTED]`, `sk-` followed by 20 or more alphanumerics becomes `sk-[REDACTED]`, `x-api-key:` plus value becomes `x-api-key: [REDACTED]` (case-insensitive where PHP is); `Wrap(next slog.Handler) slog.Handler` resolves each attr value (LogValuer), replaces values of redacted keys, recurses into groups, scrubs string values and error strings, scrubs the record message, redacts WithAttrs attrs before delegating and keeps WithGroup wrapped; `InstallDefault(w io.Writer)` calls slog.SetDefault with Wrap over slog.NewTextHandler(w, nil) and never wraps the existing default handler (that handler writes through the log package, which SetDefault redirects back into the new handler). README with the standard structure (H1, summary "Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs.", import line, Overview, Features, Usage, API reference, Dependencies, Testing) and a root README modules row reusing that sentence. Tests: `TestRedactHandler` (top-level, WithAttrs, nested group, mixed-case key, LogValuer, error value), `TestScrub` (each pattern plus a non-secret string unchanged); example_test.go `ExampleWrap`.
(2) internal/build/build.go: generateMain imports sunscreen and emits `sunscreen.InstallDefault(os.Stderr)` as the first statement of run; `TestGenerateMainInstallsRedactingLogger` asserts the line precedes compass.Load. Regenerate examples/hello/main.go and ../fonoteka.go/main.go with the built tool (build summer to a temporary path from this repo, run its build subcommand in each directory); commit the fonoteka.go main.go alone in fonoteka.go.
(3) modules/surf/recover_redaction_test.go `TestRecoverHidesPanicDetails`: a JSON-group and a raw-group handler panic with an error carrying an `sk-` key and a Bearer token; both responses equal the existing opaque bodies and contain neither secret, and a record logged through a sunscreen-wrapped handler contains neither (the SafeExceptionResponse check the todo asks for).
(4) beachcomber: `IndexDropper` interface with `DropIndex(ctx context.Context, index string) (existed bool, err error)` beside PageSearcher, and `DropIndex(ctx, e Engine, index string) (bool, error)` using it when implemented, else calling Flush and reporting existed true (documented). typesense `(*Engine).DropIndex` sends DELETE /collections/{index}; 2xx is existed true, 404 existed false, anything else the statusError. Also add the optional `IndexEnsurer` interface with `EnsureIndex(ctx, index string, schema map[string]any) error` and the helper `EnsureIndex(ctx, e Engine, index string, schema map[string]any) error` (a no-op for engines without it); typesense implements it with its existing collection-creation step, because Upsert of zero documents creates nothing and PHP's reindex creates the collection when the album table is empty. Tests `TestDropIndex` (fallback and interface paths), `TestEngineDropIndex` (httptest Typesense answering 200 then 404) and `TestEngineEnsureIndex` (creates once, a second call sends no create). README API reference and docs/services/search.md describe DropIndex and EnsureIndex.
(5) docs/services/logging.md (new, `section: services`): what is redacted, that generated mains install it, that plugins resolve `*slog.Logger` or fall back to slog.Default and log ids, never args, tokens or bodies. Neutral names only.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/sunscreen ./modules/surf ./modules/beachcomber/... ./internal/build -count=1 -race -v -run '^(TestRedactHandler|TestScrub|ExampleWrap|TestRecoverHidesPanicDetails|TestDropIndex|TestEngineDropIndex|TestEngineEnsureIndex|TestGenerateMainInstallsRedactingLogger)$' &amp;&amp; go test ./modules/sunscreen ./modules/surf ./modules/beachcomber/... ./internal/build -count=1 &amp;&amp; go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' &amp;&amp; go run ./cmd/summer docs:build --check &amp;&amp; go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go build ./...</automated>
<fails_when>Any command exits non-zero; the verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestRedactHandler, TestScrub, TestRecoverHidesPanicDetails, TestDropIndex, TestEngineDropIndex, TestEngineEnsureIndex and TestGenerateMainInstallsRedactingLogger; docs:build --check reports a problem; the application no longer builds.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'sunscreen.InstallDefault(os.Stderr)' internal/build/build.go` prints at least 1 and `grep -c 'sunscreen.InstallDefault(os.Stderr)' ../fonoteka.go/main.go` prints 1.
- `grep -c 'modules/sunscreen/README.md' README.md` prints 1.
- `grep -c 'IndexDropper' modules/beachcomber/README.md` prints at least 1.
- `ls docs/services/logging.md` succeeds and `grep -c 'section: services' docs/services/logging.md` prints 1.
</acceptance_criteria>
<done>Every application binary logs through the redacting handler, surf's 500 path provably hides panic text, and reindex has a framework call that distinguishes a dropped index from an absent one.</done>
</task>
<task type="auto">
<name>Task 5: The roadmap, requirements and project description say what Phase 14 ships (D-06, D-07, D-13, D-14)</name>
<files>.planning/REQUIREMENTS.md, .planning/ROADMAP.md, .planning/PROJECT.md</files>
<read_first>.planning/REQUIREMENTS.md (lines 28, 83, 105-106), .planning/ROADMAP.md (line 29 and the "### Phase 14:" section), .planning/PROJECT.md (lines 82, 120, 137), .planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md (D-06, D-07, D-09, D-13, D-14), .planning/notes/core-plugins-own-repos.md</read_first>
<action>Planning docs only, one commit in summercms.go containing only these three files; use Edit, never a whole-file Write.
(1) REQUIREMENTS per D-06 and D-13/D-14: INTG-02 reads "AI cover recognition through Anthropic and OpenAI-compatible adapters over the guarded client, with per-credential model and base URL overrides, plus the ai-credential/test route and the backend global vision model behind the AI resolver's admin tier" (the old SDK wording is removed). API-08 reads "Feedback submissions, widget config and the per-user hide preference from the stack feedback plugin (sitemap dropped for this application, D-14)". INTG-01 also lists `albums/match`, `albums/{id}/match`, `albums/{id}/apply-release` and `albums/import/discogs` (D-07). Leave status checkboxes and the traceability table untouched.
(2) ROADMAP per D-06, D-07, D-14: the Phase 14 summary bullet on line 29 drops "sitemap" and says feedback; Phase 14 `**Repos:**` becomes "fonoteka.go; summercms.go (14-01 framework helpers); sm-golem-plugin and sm-feedback-plugin (new core-plugin repos mounted as submodules)"; criterion 4 additionally lists `albums/match`, `albums/{id}/match`, `albums/{id}/apply-release` and `albums/import/discogs`; criterion 5 additionally lists `albums/recognize` on the JWT and personal-token groups and names the adapters as hand-rolled over the guarded client; criterion 6 reads "Feedback submissions, widget config and the hide preference work (sitemap dropped for this application, D-14); the oauth-client, prune-notifications and reindex commands all run correctly." Add one sentence under criterion 6 that `oauth-identities` GET/DELETE and token `GET /api/v1/fonoteka/me` stay pending (D-09, todo orphan-pending-routes).
(3) PROJECT.md per D-06: line 82 becomes feedback only; the "Two repositories" paragraph lists the application plugins as fonoteka and translate, and names the shared core plugins mounted as submodules: sm-user-plugin, sm-golem-plugin (`plugins/golem15/golem`) and sm-feedback-plugin (`plugins/golem15/feedback`); sitemap is no longer listed for the application (D-14).</action>
<verify>
<automated>test "$(grep -c 'Anthropic Go SDK' .planning/REQUIREMENTS.md)" = "0" &amp;&amp; grep -q 'OpenAI-compatible adapters over the guarded client' .planning/REQUIREMENTS.md &amp;&amp; grep -A16 '### Phase 14:' .planning/ROADMAP.md | grep -q 'albums/import/discogs' &amp;&amp; grep -A16 '### Phase 14:' .planning/ROADMAP.md | grep -q 'sm-feedback-plugin' &amp;&amp; grep -q 'sm-golem-plugin' .planning/PROJECT.md &amp;&amp; grep -q 'sm-feedback-plugin' .planning/PROJECT.md</automated>
<fails_when>Non-zero exit: REQUIREMENTS still carries the SDK wording or lacks the adapters wording, the Phase 14 section lacks albums/import/discogs or sm-feedback-plugin, or PROJECT.md lacks sm-golem-plugin or sm-feedback-plugin.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'API-08' .planning/REQUIREMENTS.md | head -1` shows the feedback-only text and no "sitemap output".
- The traceability rows for JOBS-02, JOBS-03, SRCH-02, INTG-01, INTG-02, API-08, CLI-05 still read `Phase 14 | Pending`.
- `grep -A16 '### Phase 14:' .planning/ROADMAP.md | grep -c 'recognize'` prints at least 1.
- `git show --name-only --format= <sha of the rewording commit>` lists exactly .planning/PROJECT.md, .planning/REQUIREMENTS.md and .planning/ROADMAP.md.
</acceptance_criteria>
<done>The planning docs describe the Phase 14 scope as decided, in a commit separate from code.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Application → vendor host | Credentials and user data leave through the guarded client |
| Configuration and request input → client policy | Must never select the trusted mode or the transport |
| Reference backend → recording proxy | Real credentials pass a local MITM proxy during recording |
| Recorded sidecar → git | Fixtures are committed |
| Log records → log sink | Errors and attrs may carry secrets |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-01 | Tampering | fetchguard.Client guarded modes | high | mitigate | https-only, AllowHosts check, dial-time reserved-IP Control on every connection, redirects never followed; TestClientModes, TestClientNeverFollowsRedirects, TestClientSchemeGuard (Tasks 1-2). |
| T-14-02 | Elevation of Privilege | trusted mode and transport override | high | mitigate | TrustedMode only in Go code; transport only through WithTransport's unexported context key; TestTransportSeamIsCodeOnly (Task 1). |
| T-14-03 | Information Disclosure | application logs | high | mitigate | sunscreen.Wrap installed first in every generated main; redacted keys and scrub patterns; surf 500 hides panic text; TestRedactHandler, TestScrub, TestRecoverHidesPanicDetails, TestGenerateMainInstallsRedactingLogger (Task 4). |
| T-14-04 | Information Disclosure | committed sidecars | high | mitigate | WriteUpstream masks var values and refuses unmasked Authorization/X-Api-Key; TestWriteUpstreamRefusesUnmaskedCredential (Task 3); check_corpus --check-secrets scans sidecars from 14-02. |
| T-14-05 | Spoofing | recording proxy and CA | medium | mitigate | Loopback listen only, CA key 0600 outside the fixtures tree, forward mode only by explicit flag; TestUpstreamProxyRefusesNonLoopback, TestEnsureParityCA, TestParityCommandContract (Task 3). |
| T-14-06 | Denial of Service | vendor response bodies | medium | mitigate | MaxBytes cap on Do and Send; TestClientBodyCap (Task 2). |
| T-14-07 | Tampering | beachcomber.DropIndex | medium | mitigate | Deletes only the named index and reports existence; TestEngineDropIndex (Task 4). |
| T-14-SC | Tampering | package installs | low | accept | No npm, pip, cargo or Go module is added (D-02; RESEARCH Package Legitimacy Audit lists none); crypto/x509 and log/slog are stdlib. |
</threat_model>
<verification>
- summercms.go: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$'` and `go run ./cmd/summer docs:build --check` green.
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1 -short` green with the regenerated main.go.
</verification>
<success_criteria>
- The guarded client, sidecar fake, recording proxy, redacting handler and IndexDropper exist with tests, READMEs and docs.
- Generated mains install the redacting handler.
- REQUIREMENTS, ROADMAP and PROJECT carry D-06, D-07, D-13 and D-14 in a docs-only commit.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-01-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,361 @@
---
phase: 14-domain-jobs-and-external-integrations
plan: 02
type: execute
wave: 2
depends_on: ["14-01"]
files_modified:
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_limiter.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_store.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/clock.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/errors.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/mapper.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/input_parser.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/scorer.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/applicator.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/import_resolver.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/price_suggestion.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/discogs_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/postgres_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/discogstest/fake_clock.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_canonical_matcher.go
- ../fonoteka.go/plugins/golem15/fonoteka/updates/22_discogs_rate_windows.go
- ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/discogs_wiring.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/jobs.go
- ../fonoteka.go/plugins/golem15/fonoteka/csv_match_job.go
- ../fonoteka.go/plugins/golem15/fonoteka/csv_import_job.go
- ../fonoteka.go/plugins/golem15/fonoteka/wishlist_digest_job.go
- ../fonoteka.go/plugins/golem15/fonoteka/mail.go
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest.htm
- ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest-en.htm
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/schedule.go
- ../fonoteka.go/plugins/golem15/fonoteka/schedule_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/wishlist_digest_job_test.go
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/upstream_replay_test.go
- ../fonoteka.go/parity/fonoteka_seed_test.go
- ../fonoteka.go/parity/fonoteka_reset.php
- ../fonoteka.go/parity/php_parity.sh
- ../fonoteka.go/parity/check_corpus.go
- ../fonoteka.go/parity/check_corpus_test.go
- ../fonoteka.go/parity/schema_diff_test.go
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/fixtures/routes/
- ../fonoteka.go/parity/fixtures/jobs/
- ../fonoteka.go/parity/upstream/scripts/
- ../fonoteka.go/parity/README.md
autonomous: true
requirements: [JOBS-02, JOBS-03, SRCH-02, CLI-05, INTG-01]
estimate:
tokens: 420000
raw_tokens: 420000
tasks: 4
confidence: low
must_haves:
truths:
- "Per D-08, the fonoteka plugin installs a real `classes.ReleaseFetcher` at Boot (DiscogsClient::getRelease plus DiscogsMapper::mapRelease), so `PATCH import/csv/{id}/rows/{rowId}` with a candidate `selected_discogs_id` answers 200 with the row `resolved` and the same draft PHP stores; a Discogs 404 answers 422 `discogs_unavailable` and an exhausted rate budget 422 `discogs_rate_limited`, each recorded against PHP with its upstream sidecar (D-11, D-15)."
- "Per D-21 (resolving D-17), the Discogs limiter state lives in the UNLOGGED table `golem15_fonoteka_discogs_rate_windows` (bucket TEXT primary key, window_start TIMESTAMPTZ, hits INTEGER) and one `INSERT … ON CONFLICT (bucket) DO UPDATE … WHERE … RETURNING` grants a slot; the bucket id is the first 32 hex characters of HMAC-SHA256(token, app key) and the token itself is never stored or logged."
- "Per D-16, the limiter, the client's 429 loop and the match job's delay take a `discogs.Clock` (Now, Sleep(ctx, d)); tests drive a fake clock with no real sleeps and assert the 240 s job timeout and the re-dispatch delay as values."
- "Per INTG-01, the Discogs client sends `Authorization: Discogs token=<token>`, `User-Agent` from `golem15.fonoteka.discogs.user_agent` and `Accept: application/json` to the code-constant base `https://api.discogs.com` through a guarded fetchguard client (AllowHostsMode api.discogs.com, 10 s); 404 is a nil result, 401/403 a token-rejected error, 429 a Retry-After wait inside the 15 s budget or a rate-limit error, any other status `Discogs request failed with HTTP status N.`; logs carry only status and path."
- "Per D-10, `UpdateCsvMapping`, the `UpdateCsvRow` save and `CancelCsvImport` lock the import row (SELECT … FOR UPDATE) or compare-and-swap its status inside one lagoon.Transaction and re-check the before-commit rule on the locked row; the Discogs fetch of a pick runs before the lock; response shapes are unchanged, and racing commit against each of them queues at most one import job."
- "Per JOBS-02, the match worker (kind golem15.fonoteka.csv_match, queue fonoteka_csv_match, `conga.Timeout(240*time.Second)`) and the import worker (golem15.fonoteka.csv_import, queue fonoteka_csv_import) port AlbumCsvMatchJob and AlbumCsvImportJob: summer_jobs start, progress, complete, fail and stop outcomes as PHP writes them; a Discogs rate limit pauses the batch and re-dispatches the match job with a delay instead of failing it; any other error marks the import failed, calls FailJob and returns nil so River does not retry."
- "Per JOBS-03, the digest worker (golem15.fonoteka.wishlist_digest on fonoteka_wishlist_digest) reads the queue row, skips a missing row or item_count ≤ 0 with `{\"skipped\":true}`, otherwise deletes the row, mails `wishlist_subscription_digest` (or `-en` when the subscriber's preferred_locale is en) with ownerName, itemCount and wishlistName, and completes with `{\"sent\":N}`; a list that is no longer a wishlist or a missing subscriber sends nothing."
- "Per CLI-05 and SRCH-02, `fonoteka:prune-notifications` and `fonoteka:reindex [--drop-old-items-index]` are registered in `Commands()`, print PHP's messages and exit codes, the scheduled prune entry resolves, and reindex refuses when Typesense is not configured, aborts when any album has collection_id NULL or ≤ 0, rebuilds the index, fails when a `collection_id:=0` document exists afterwards, and with the flag prints `Deleted legacy Typesense collection: …` or `Legacy Typesense collection already absent: …` through beachcomber.DropIndex."
- "Edge (JOBS-02 boundary): with 0 search results a row becomes matched with candidates [], with exactly 1 the release is fetched, mapped into draft_json and the row is matched, with 2 or more the first 10 (MAX_CANDIDATES) are kept, draft_json is null and the row is matched_ambiguous; 11 results keep 10."
- "Edge (JOBS-02 precision): the re-dispatch delay is an integer number of seconds, max(retryAfterSeconds, secondsUntilAvailable), where a rate-limit error carries max(1, 60 − floor(now − window_start)), so a 0.4 s remainder becomes 1 s and never 0."
- "Edge (INTG-01 boundary): with rate_threshold 50, the 50th acquire in a window is granted and the 51st waits; a wait equal to the remaining 15 s budget sleeps and one second more raises the rate-limit error; a window older than 60 s restarts at hits 1."
- "Edge (INTG-01 precision): Retry-After is read as integer seconds; an absent or non-numeric header uses retry_after_fallback_seconds (10), and syncFromHeaders only raises hits to max(0, threshold − X-Discogs-Ratelimit-Remaining) inside an active window, never lowers them."
- "Edge (INTG-01 concurrency): two concurrent acquires from separate database sessions at hits = threshold − 1 grant exactly one slot (TestDiscogsRateWindowConcurrent on real Postgres)."
- "Edge (CLI-05 adjacency): prune deletes notifications with created_at strictly before now − 90 days; a row exactly 90 days old is kept."
- "Edge (CLI-05 empty): prune with nothing to delete prints `Pruned 0 notifications older than 90 days.` and exits 0; reindex on an empty album table creates the index from the album schema (beachcomber.EnsureIndex) and succeeds."
- "Edge (CLI-05 idempotency): a second prune deletes 0; a second reindex yields the same document count; a second `--drop-old-items-index` prints the already-absent message."
- statement: "Edge (CLI-05 ordering): reindex upserts albums ordered by id in batches of 500, so equal timestamps never change which documents are written."
verification: backstop
- statement: "Edge (CLI-05 concurrency): two prune runs at once delete each old row once (a single DELETE … WHERE created_at < cutoff) and both exit 0."
verification: backstop
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go"
provides: "Client, ClientConfig, NewClient, ForUser, GetRelease, GetPriceSuggestions, GetIdentity, GetMasterVersions, SearchByBarcode, SearchByQuery, BaseURI"
contains: "https://api.discogs.com"
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_store.go"
provides: "RateStore, PostgresRateStore, MemoryRateStore"
contains: "ON CONFLICT"
- path: "../fonoteka.go/plugins/golem15/fonoteka/updates/22_discogs_rate_windows.go"
provides: "UNLOGGED golem15_fonoteka_discogs_rate_windows migration"
contains: "CREATE UNLOGGED TABLE"
- path: "../fonoteka.go/plugins/golem15/fonoteka/csv_match_job.go"
provides: "matchCsv worker and deliverCsvMatch"
contains: "deliverCsvMatch"
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go"
provides: "ReindexCommand (fonoteka:reindex)"
contains: "drop-old-items-index"
key_links:
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go"
via: "Boot calls classes.SetReleaseFetcher with the Discogs-backed fetcher from discogs_wiring.go"
pattern: "SetReleaseFetcher"
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go"
to: "summercms.go modules/fetchguard/client.go"
via: "every Discogs request goes through fetchguard.Client in AllowHostsMode"
pattern: "fetchguard.NewClient"
- from: "../fonoteka.go/parity/parity_test.go"
to: "summercms.go modules/tide/upstream.go"
via: "replayPortedRoute loads <fixture>.upstream.yaml and wraps the handler with fetchguard.WithTransport(tide.NewUpstreamFake)"
pattern: "LoadUpstream"
prohibitions:
- requirement_id: INTG-01
category: fairness
statement: "One account's Discogs use MUST NOT spend another account's budget: the limiter bucket is per token (HMAC of the token), never global or per IP"
status: resolved
verification: test
- requirement_id: INTG-01
category: transparency
statement: "A CSV row pick MUST NOT fetch or write a release the user did not choose: only an id from the row's candidates_json is fetched and written"
status: resolved
verification: test
- requirement_id: JOBS-03
category: values
statement: "The digest MUST NOT mail a subscriber more than once per queue row (one per 30-minute window) and MUST NOT mail about a list that is no longer a wishlist"
status: resolved
verification: test
- requirement_id: CLI-05
category: safety
statement: "reindex --drop-old-items-index MUST NOT delete any index other than the legacy golem15_fonoteka_items collection (with the configured search prefix)"
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's CSV import now finishes. The match job finds Discogs candidates and survives Discogs rate limits, a picked candidate resolves to the release PHP would store, the import job writes the albums, wishlist subscribers get their digest mail, and the operator can prune notifications and rebuild the search index (JOBS-02, JOBS-03, SRCH-02, CLI-05, the Discogs half of INTG-01; ROADMAP SC1-SC3 and SC6's commands).
<objective>
Port the Discogs client with its Postgres limiter and the pure Discogs domain classes, install the real ReleaseFetcher, fix WR-02, add the CSV match and import workers with the CSV write-service variants, the digest worker with its mail templates, and the prune and reindex commands; wire upstream sidecars into the parity harness and record the row-edit pick cases.
Purpose: Phase 13 queued these jobs with no worker (13 D-04); this plan makes them run exactly as PHP does. Decisions: D-08, D-10, D-11, D-15, D-16, D-17, D-21; research Pitfalls 4-7.
Output: `classes/discogs` package, three workers, two commands, migration, mail templates, parity sidecar hook and recordings.
Repo: fonoteka.go only. Commits path-scoped; never add co-author tags. Assumptions recorded below.
</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-PATTERNS.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-01-SUMMARY.md
@../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go
<interfaces>
- From 14-01: `fetchguard.NewClient(policy, cfg)`, `(*Client).Get/PostJSON/Send/Do`, `Result{Body, ContentType, StatusCode, Header}`, `fetchguard.WithTransport(ctx, rt)`; `tide.UpstreamPath`, `tide.LoadUpstream`, `tide.NewUpstreamFake(sidecar, store)`, `(*UpstreamFake).Verify`; `summer parity:upstream` (listen 127.0.0.1:8425, script mode); `beachcomber.DropIndex`, `beachcomber.EnsureIndex`, `beachcomber.SearchPage`.
- Phase 13: `classes.CsvImportKind/Queue/Label`, `CsvMatchKind/Queue/Label`, `WishlistDigestKind`, `WishlistDigestJobQueue`, `WishlistDigestLabel`, `WishlistDigestDelay`, `CsvImportArgs{CsvImportID}`, `CsvMatchArgs{CsvImportID}`, `WishlistDigestArgs{SubscriberID, WishlistCollectionID}`; `classes.CsvJobs` (Dispatch, CancelJob); `ReleaseFetcher` (returns `map[string]any` today), `SetReleaseFetcher`, `ErrDiscogsUnavailable`, `ErrDiscogsRateLimited`; `csvBeforeCommit`, `cancelCsvJob`, `CommitCsvImport` (reference CAS); `classes.DiscogsAllowed`, `ResolveDiscogsConfig` (→ `*DiscogsConfig`), `MarketCurrency`; `classes.CoverImporter`, `CoverImporterFromConfig`; `csv.Map` ordered map (classes/csv/ordered.go); `classes/csv/canonical_id.go` `CanonicalID`.
- conga: `Job(fn, OnQueue, MaxAttempts, Timeout)`, `JobID(ctx)`, `(*Manager).Dispatch/StartJob/UpdateJobState/CompleteJob/FailJob/StopJob/CheckIfCanceled/CancelJob`, `DispatchOpts{Label, Queue, Count, Metadata, Delay}`.
- PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{classes/discogs/*.php, jobs/{AlbumCsvMatchJob,AlbumCsvImportJob,WishlistDigestJob}.php, classes/AlbumWriteService.php (lines 64-373), classes/csv/CsvCanonicalIdMatcher.php, classes/NotificationService.php (lines 204-232), console/{PruneNotifications,ReindexAlbums}.php, controllers/api/CsvImportApiController.php (updateRow), config/fonoteka.php (lines 18-30), views/mail/wishlist_subscription_digest*.htm, tests/unit/{DiscogsClientTest,DiscogsRateLimiterTest,DiscogsMapperTest,DiscogsInputParserTest,ReleaseMatchScorerTest,PriceSuggestionResolverTest,DiscogsImportResolverTest,AlbumReleaseApplicator*Test,WishlistDigestJobTest}.php}.
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- Package `classes/discogs` (package `discogs`): `BaseURI`, `Clock`, `SystemClock`, `Client`, `ClientConfig`, `NewClient`, `ForUser`, `(*Client).GetRelease`, `GetPriceSuggestions`, `GetIdentity`, `GetMasterVersions`, `SearchByBarcode`, `SearchByQuery`, `RateLimiter`, `NewRateLimiter`, `LimiterConfig`, `(*RateLimiter).Acquire`, `SecondsUntilAvailable`, `SyncFromHeaders`, `RegisterRetryAfter`, `BucketID`, `RateStore`, `PostgresRateStore`, `MemoryRateStore`, `RateLimitError{RetryAfterSeconds}`, `ErrTokenRejected`, `RequestError{Status}`, `MapRelease`, `MapSearchResult`, `MapMasterVersion`, `ParseInput`, `NormalizeBarcode`, `AlternateBarcode`, `ScoreRelease` (ReleaseMatchScorer), `Applicator`, `ApplyResult`, `ImportResolver`, `ResolvePriceSuggestion`.
- Package `classes/discogs/discogstest`: `FakeClock`, `NewFakeClock`.
- classes: `ReleaseFetcher.FetchRelease` now returns `*csv.Map`; CSV write-service ports `FillEmptyFromCsv`, `ApplyCsvFill`, `ApplyCsvOverwrite`, `CreateCsvAlbum`, `ResolveGenreID`, `SyncCsvRating` (album_write_service.go); `MatchCanonicalCsvAlbum` (csv_canonical_matcher.go); `CsvJobRunner` interface.
- Root package: `discogsReleaseFetcher` (discogs_wiring.go), workers `matchCsv`/`deliverCsvMatch`, `importCsv`/`deliverCsvImport`, `sendWishlistDigest`/`deliverWishlistDigest`.
- Console: `PruneNotificationsCommand(app)` (`fonoteka:prune-notifications`), `ReindexCommand(app)` (`fonoteka:reindex`, `--drop-old-items-index`).
- Migration `202610030001_create_discogs_rate_windows` (UNLOGGED `golem15_fonoteka_discogs_rate_windows`).
- Config keys: `golem15.fonoteka.discogs.user_agent`, `golem15.fonoteka.discogs.rate_threshold`, `golem15.fonoteka.discogs.wait_budget_seconds`, `golem15.fonoteka.discogs.retry_after_fallback_seconds`.
- Mail templates `golem15.fonoteka::mail.wishlist_subscription_digest` and `-en`.
- Parity: sidecar replay hook in parity_test.go, `parity/upstream/scripts/` vendor script files, row-edit pick cases with `*.upstream.yaml`, `fixtures/jobs/csv-match.upstream.yaml` and `csv-match.rows.json`, sidecars scanned by `check_corpus --check-secrets`.
- Tests: `TestClientGetRelease`, `TestDiscogsClientStatuses`, `TestRateLimiterBudget`, `TestRateLimiterSyncFromHeaders`, `TestRegisterRetryAfter`, `TestDiscogsRateWindowConcurrent`, `TestMapReleaseFixture`, `TestDiscogsDomainVectors`, `TestCsvRowPickResolves`, `TestCsvRowPickSeam`, `TestCsvWR02`, `TestCsvMatchJob`, `TestCsvImportJob`, `TestJobContractDispatchWhileWorkerRuns`, `TestWishlistDigestJob`, `TestPruneNotifications`, `TestReindexCommand`, `TestFonotekaSchedulePrune`.
## Assumptions
- EDGE-UNCLASSIFIED (JOBS-03, flagged): the edge probe could not classify the digest requirement; this plan assumes the coalescing window is entirely the 1800 s dispatch delay Phase 13 already sets on the first queue-row insert, and the worker only reads, deletes and mails. Not auto-resolved.
- EDGE-UNCLASSIFIED (SRCH-02, flagged): the probe could not classify the reindex requirement; this plan assumes "before" is PHP's database check (no album with collection_id NULL or ≤ 0) and "after" is the Typesense `found` check on `collection_id:=0`. Not auto-resolved.
- A5: Scout imports in chunks of 500; the Go reindex uses 500.
- A7/A8: the limiter SQL is the research shape; `jobs.CancelJob` touches only summer_jobs and river_job, so it cannot deadlock against the csv_imports row lock.
- A1: PHP's curl honours HTTPS_PROXY and the parity CA passed with `-d curl.cainfo` and `-d openssl.cafile`; Task 1's precondition checks it before any recording.
- Pitfall 4: new job goldens compare summer_jobs metadata JSON semantically, because conga marshals map keys sorted and PHP keeps insertion order.
<assumption_delta_decision>
signal: pluralization
term: fallback
decision: no-change
rationale: "retry-after fallback" is the single Discogs Retry-After default (retry_after_fallback_seconds = 10) used when the header is absent or non-numeric; it is not a second identity or a plural concept.
</assumption_delta_decision>
<tasks>
<task type="tracer">
<name>Task 1: A collector picks a Discogs candidate on a CSV row and the row resolves with the draft PHP stores, replayed offline from PHP's recorded Discogs exchange</name>
<reversibility rating="costly">The UNLOGGED limiter table and the `<fixture>.upstream.yaml` sidecar layout become shared by every later Discogs, AI and G15Office case; both are re-creatable, so no checkpoint.</reversibility>
<precondition>`php -v` exits 0, the isolated PHP instance resets with `parity/php_parity.sh reset`, and a probe `HTTPS_PROXY=http://127.0.0.1:8425 php -d curl.cainfo=<parity CA> -r` request to https://api.discogs.com reaches a running `summer parity:upstream` (research assumption A1).</precondition>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_limiter.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_store.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/clock.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/errors.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/mapper.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/discogs_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/postgres_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/discogstest/fake_clock.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/22_discogs_rate_windows.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/discogs_wiring.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/upstream_replay_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/manifest.yaml, ../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/classes/discogs/DiscogsClient.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsRateLimiter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsMapper.php (mapRelease), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsRateLimitException.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsTokenRejectedException.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvImportApiController.php (updateRow), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php (lines 18-30), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/unit/DiscogsMapperTest.php, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go (ReleaseFetcher lines 589-628, UpdateCsvRow 639-705, CommitCsvImport 825-882), ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go (config constants and FromConfig), ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/ordered.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (ResolveDiscogsConfig, DiscogsAllowed, MarketCurrency), ../fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/registry.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Boot), ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go (TestCsvRowPickSeam, fakeReleaseFetcher), ../fonoteka.go/parity/parity_test.go (replayPortedRoute), ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fonoteka_seed_test.go (seedParityCredentials, seedParityCSVImports), ../fonoteka.go/parity/check_corpus.go (secret scan), ../fonoteka.go/parity/schema_diff_test.go (Go-only allow-list), .planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md (sections "Discogs client", "D-17", "WR-02 fix", "D-11")</read_first>
<action>Per D-08, D-11, D-15, D-16, D-19, D-21 (the thinnest path: route → service → fetcher → client → limiter table → guarded client → sidecar fake).
(1) classes/discogs: clock.go `Clock` interface (`Now() time.Time`, `Sleep(ctx, d) error` returning ctx.Err() on cancel) and `SystemClock`; discogstest/fake_clock.go `FakeClock` whose Sleep advances Now. errors.go `RateLimitError{RetryAfterSeconds int}`, `ErrTokenRejected`, `RequestError{Status int}` with PHP's message. rate_store.go `RateStore` (`TryAcquire(ctx, bucket string, now time.Time, threshold int) (granted bool, windowStart time.Time, err error)`, `Tighten(ctx, bucket, now, implied int) error`, `WindowStart(ctx, bucket) (time.Time, bool, error)`), `PostgresRateStore{DB *gorm.DB}` with the research single-statement upsert (now passed as a parameter, never SQL now()), and `MemoryRateStore` for unit tests. rate_limiter.go `LimiterConfig{Threshold int; WaitBudget time.Duration; RetryAfterFallback int}`, `NewRateLimiter(store, clock, appKey []byte, cfg)`, `BucketID(token, appKey)` (first 32 hex of HMAC-SHA256), `(*RateLimiter).Acquire(ctx, token) error` (granted → nil; otherwise wait = max(1, 60 − floor(now − window_start)); a wait beyond the remaining budget returns `*RateLimitError{max(1, wait)}`, else Sleep and retry). client.go `BaseURI = "https://api.discogs.com"` (code constant), `ClientConfig{Token, UserAgent, MarketCurrency string; HTTP *fetchguard.Client; Limiter *RateLimiter; Clock Clock; WaitBudget time.Duration}`, `NewClient`, `ForUser(ctx, db, cfg, user)` (ResolveDiscogsConfig, `MarketCurrency(cfg)`, fetchguard AllowHostsMode `api.discogs.com` 10 s), and `GetRelease(ctx, id int) (map[string]any, error)` on the PHP request loop (acquire once; 200 decodes with UseNumber, 404 returns nil, 401/403 ErrTokenRejected, 429 handled in Task 2, others RequestError); the request context reaches fetchguard so WithTransport applies. mapper.go `MapRelease(release map[string]any) *csv.Map` builds PHP's draft keys in PHP order.
(2) Migration updates/22_discogs_rate_windows.go ID `202610030001_create_discogs_rate_windows`: `CREATE UNLOGGED TABLE golem15_fonoteka_discogs_rate_windows (bucket TEXT PRIMARY KEY, window_start TIMESTAMPTZ NOT NULL, hits INTEGER NOT NULL)`, rollback drops it; register through updates.Register; add the table to schema_diff_test's Go-only allow-list with the D-21 reason. config.yaml under `discogs:` adds `user_agent: "FonotekaApp/1.0 +https://github.com/golem15com/wn-fonoteka-plugin"`, `rate_threshold: 50`, `wait_budget_seconds: 15`, `retry_after_fallback_seconds: 10` with comments naming the SUMMER_ override; the base URI stays code.
(3) ReleaseFetcher: change `FetchRelease` to return `*csv.Map` (the ordered draft) and update the default and the test fake. discogs_wiring.go (root package) `discogsReleaseFetcher{app}` resolves db and config per call, builds the client with ForUser, maps nil to ErrDiscogsUnavailable and `*RateLimitError` to ErrDiscogsRateLimited; plugin.go Boot installs it with SetReleaseFetcher. In UpdateCsvRow the fetch stays before any lock; the save closure runs in one lagoon.Transaction that re-reads the import with `clause.Locking{Strength: "UPDATE"}`, re-checks csvBeforeCommit and the row, then writes `resolved`, the selected id and draft_json (D-10 for this path).
(4) Parity: parity_test.go — for each case, when `tide.UpstreamPath(fixture)` exists, load it, build `tide.NewUpstreamFake(sidecar, store)`, serve the case through a handler wrapper that puts `fetchguard.WithTransport` on each request context, and fail the case when `Verify` reports anything; upstream_replay_test.go `TestUpstreamSidecarsAreReplayed` asserts every sidecar on disk belongs to a manifest case or a job golden. check_corpus `--check-secrets` scans `*.upstream.yaml` too (check_corpus_test covers a planted Discogs token). php_parity.sh `serve` honours `PARITY_UPSTREAM_CA`: when set it exports HTTPS_PROXY and https_proxy as `http://127.0.0.1:8425` and passes the CA with `-d curl.cainfo` and `-d openssl.cafile`. fonoteka_reset.php and the Go seed give alice a BYOK Discogs token held as `{{secret:discogs-token}}` (matching `^[A-Za-z0-9_\-]{10,255}$`) and a CSV import row whose candidates_json offers a fixed release id. parity/upstream/scripts/discogs-release.yaml holds the scripted `GET /releases/{id}?curr_abbr=EUR` 200 response built from the DiscogsMapperTest release fixture. Record with `summer parity:upstream --mode script` running and PHP serving through it: the pick-success case of `PATCH /_fonoteka/api/v1/import/csv/{id}/rows/{rowId} jwt` plus its sidecar; add the case to the manifest (the route is already ported). README gains the "Upstream sidecars" recipe.
(5) Tests: discogs_test.go `TestClientGetRelease` (headers, URL, UseNumber decode, 404 nil, through a tide fake), `TestMapReleaseFixture` (DiscogsMapperTest's release maps to PHP's draft bytes); postgres_test.go `TestRateLimiterPostgresAcquire` (testcontainers Postgres, migration applied, threshold reached then a new window); csv_smoke_test.go `TestCsvRowPickResolves` (fake fetcher draft is written, row resolved, PHP key order kept) and `TestCsvRowPickSeam` updated (Discogs disallowed still answers discogs_unavailable; a non-candidate id still validation_failed).</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 '^(TestClientGetRelease|TestMapReleaseFixture|TestRateLimiterPostgresAcquire)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestCsvRowPickResolves|TestCsvRowPickSeam)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed|TestSchemaMatchesPHPSnapshot)$' &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 TestClientGetRelease, TestMapReleaseFixture, TestRateLimiterPostgresAcquire, TestCsvRowPickResolves, TestUpstreamSidecarsAreReplayed and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret, an unrecorded route or a ported case-status mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'CREATE UNLOGGED TABLE golem15_fonoteka_discogs_rate_windows' ../fonoteka.go/plugins/golem15/fonoteka/updates/22_discogs_rate_windows.go` prints 1.
- `grep -c 'SetReleaseFetcher' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go` prints at least 1.
- `ls ../fonoteka.go/parity/fixtures/routes/ | grep -c 'upstream.yaml'` prints at least 1, and that sidecar contains `{{secret:discogs-token}}` and no 10+ character literal token after `Discogs token=`.
- `grep -c 'PARITY_UPSTREAM_CA' ../fonoteka.go/parity/php_parity.sh` prints at least 1.
- `grep -c 'golem15_fonoteka_discogs_rate_windows' ../fonoteka.go/parity/schema_diff_test.go` prints 1.
</acceptance_criteria>
<done>A pick on a CSV row resolves through the real Discogs client and the shared limiter exactly as PHP did, and the harness replays PHP's recorded Discogs exchange offline while asserting Go's request.</done>
</task>
<task type="auto">
<name>Task 2: The Discogs client survives rate limits, rejected tokens and odd statuses like PHP, and every Discogs domain rule the routes need is ported</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_limiter.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_store.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/mapper.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/input_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/scorer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/applicator.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/import_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/price_suggestion.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/discogs_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/postgres_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/upstream/scripts/</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsClient.php (request loop), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsRateLimiter.php (acquire, secondsUntilAvailable, syncFromHeaders, registerRetryAfter), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsMapper.php (mapSearchResult, mapMasterVersion), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsInputParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/ReleaseMatchScorer.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/AlbumReleaseApplicator.php, /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/tests/unit/{DiscogsClientTest,DiscogsRateLimiterTest,DiscogsInputParserTest,ReleaseMatchScorerTest,PriceSuggestionResolverTest,DiscogsImportResolverTest,AlbumReleaseApplicatorCoverOnlyTest,AlbumReleaseApplicatorDryRunTest,DiscogsCandidateMapperTest}.php, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go</read_first>
<action>Per D-16, D-21 and INTG-01.
(1) Client: the full PHP loop — acquire once, then per response SyncFromHeaders(`X-Discogs-Ratelimit-Remaining`); 429 computes `wait = RegisterRetryAfter(Retry-After)` and returns `*RateLimitError{max(1, wait)}` when wait exceeds the remaining WaitBudget, else Clock.Sleep and retry. Add `GetPriceSuggestions(ctx, id)` (`/marketplace/price_suggestions/{id}`, an empty object is normal), `GetIdentity(ctx)` (`/oauth/identity`), `GetMasterVersions(ctx, id)` (`/masters/{id}/versions?per_page=10`), `SearchByBarcode(ctx, barcode)` and `SearchByQuery(ctx, q)` (`/database/search?…&type=release`); ids are ints in the path, free text only in the query. Logs through the resolved logger carry status and path only. Limiter: `SecondsUntilAvailable(ctx, token) (int, error)` (read-only, 1 when free), `SyncFromHeaders(ctx, token, remaining string)` (tighten-only per the research SQL), `RegisterRetryAfter(header string) int` (integer seconds else the fallback).
(2) Domain ports, each a straight port with PHP key order via `*csv.Map` where output is JSON: mapper.go `MapSearchResult`, `MapMasterVersion`; input_parser.go `ParseInput`, `NormalizeBarcode`, `AlternateBarcode`; scorer.go `ScoreRelease` (ReleaseMatchScorer, using completeness.go); applicator.go `Applicator{CoverImporter *classes.CoverImporter; DB *gorm.DB}` with `Apply(ctx, album, mapped, overwriteAll, coverOnly, dryRun) (ApplyResult, error)` (`ApplyResult{Filled, Remaining []string; Draft *csv.Map}`), importing covers only through CoverImporter's AllowHosts fetch; import_resolver.go `ImportResolver` (input URL or id → release draft, barcode → candidates or no_match, master → versions); price_suggestion.go `ResolvePriceSuggestion`. These have no route yet; plan 14-03 mounts them.
(3) Tests: discogs_test.go `TestDiscogsClientStatuses` (429 inside budget retries after a fake-clock sleep, 429 beyond budget returns RateLimitError with max(1, wait), 401 and 403 ErrTokenRejected, 500 RequestError message, headers tighten the limiter), `TestRateLimiterBudget` (threshold 50: 50th granted, 51st waits; wait equal to the remaining budget sleeps, one second more errors; window rollover), `TestRateLimiterSyncFromHeaders` (only raises), `TestRegisterRetryAfter` (numeric, absent, non-numeric), `TestDiscogsDomainVectors` (table cases transcribed from each PHP unit test named in read_first for parser, scorer, mapper, applicator dry-run and cover-only, import resolver and price resolver); postgres_test.go `TestDiscogsRateWindowConcurrent` (two sessions at threshold − 1, exactly one granted).
(4) Record the two remaining pick cases with sidecars (script files for a 404 release and a 429 with `Retry-After: 30`): 422 `discogs_unavailable` and 422 `discogs_rate_limited`; add them to the manifest.</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 '^(TestDiscogsClientStatuses|TestRateLimiterBudget|TestRateLimiterSyncFromHeaders|TestRegisterRetryAfter|TestDiscogsDomainVectors|TestDiscogsRateWindowConcurrent|TestClientGetRelease|TestMapReleaseFixture)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$'</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 TestDiscogsClientStatuses, TestRateLimiterBudget, TestDiscogsDomainVectors and TestDiscogsRateWindowConcurrent; the parity run reports FAIL for a row-edit case or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'time.Sleep' ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/client.go ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/rate_limiter.go` prints 0 for both files (all waits go through Clock).
- `grep -c 'func ScoreRelease(' ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/scorer.go` and `grep -c 'func ResolvePriceSuggestion(' ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/price_suggestion.go` each print 1.
- The manifest's row-edit route lists the pick-success, discogs_unavailable and discogs_rate_limited cases, each with a sidecar on disk.
</acceptance_criteria>
<done>The Discogs client, limiter and domain rules behave as PHP's for every status and vector its unit tests pin, ready for the routes in 14-03.</done>
</task>
<task type="auto">
<name>Task 3: A committed CSV import is matched against Discogs and written into the collection by real workers, pausing instead of failing on a Discogs rate limit</name>
<reversibility rating="costly">Registers workers for the 13-01 job kinds and queues that queued rows already carry; the kinds, queues and labels are unchanged.</reversibility>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_canonical_matcher.go, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_match_job.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_import_job.go, ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/fixtures/jobs/, ../fonoteka.go/parity/upstream/scripts/, ../fonoteka.go/parity/README.md</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvMatchJob.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/AlbumCsvImportJob.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (lines 64-373), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvCanonicalIdMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go (UpdateCsvMapping 519-587, CommitCsvImport 825-882, CancelCsvImport 889-905), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/canonical_id.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_broadcast.go, summercms.go modules/conga/conga.go and job.go, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-REVIEW.md (WR-02, lines 131-158), .planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md ("The three PHP jobs", Patterns 1-2, Pitfalls 4-7)</read_first>
<action>Per D-10, D-15, D-16 and JOBS-02.
(1) WR-02 first: UpdateCsvMapping and CancelCsvImport run inside one lagoon.Transaction that re-reads the import FOR UPDATE, re-checks csvBeforeCommit (mapping) on the locked row, cancels the job ids read from the locked row, writes and dispatches; response shapes unchanged. `TestCsvWR02` (Postgres, goroutines with a barrier) races commit against mapping, against a row save and against cancel, and asserts at most one import job dispatched and no import left in a state PHP cannot reach.
(2) Write-service ports in album_write_service.go: `FillEmptyFromCsv`, `ApplyCsvFill`, `ApplyCsvOverwrite`, `CreateCsvAlbum` (with PHP's options: allow_null_format, first cover URL plus import_covers through CoverImporter), `ResolveGenreID`, `SyncCsvRating`; csv_canonical_matcher.go `MatchCanonicalCsvAlbum(ctx, tx, importer, collectionID, row)` ports CsvCanonicalIdMatcher's scoped query.
(3) Workers, each a thin `p.xxx` resolver plus a testable `deliverXxx(ctx, deps, jobID uint, args)` free function (the wrapper passes `conga.JobID(ctx)`): classes gains `CsvJobRunner` (Dispatch, CancelJob, StartJob, UpdateJobState, CompleteJob, FailJob, StopJob, CheckIfCanceled; *conga.Manager satisfies it). csv_match_job.go ports AlbumCsvMatchJob line by line: import gone → FailJob `{"error":"import_not_found"}`; cancel check → StopJob; terminal or superseded → CompleteJob `{"skipped":"<status>"}`; status matching, StartJob(row_count), resume progress; per pending row (by row_index) cancel check, matchRow (duplicate → matched_csv; gate off → matched_csv; SearchByQuery then SearchByBarcode; 0/1/many with MAX_CANDIDATES 10, GetRelease plus MapRelease for exactly one) and UpdateJobState; end preview, error_message null, CompleteJob `{"matched":N}`. A `*discogs.RateLimitError` pauses: delay = max(RetryAfterSeconds, SecondsUntilAvailable), Dispatch a new CsvMatchArgs (label fonoteka.csv.match, queue fonoteka_csv_match, Count pending, Metadata csv_import_id, resumed_after_rate_limit true, retry_after delay, `Delay` that many seconds), store match_job_id, CompleteJob `{"paused":"discogs_rate_limited","retry_after":delay,"next_job_id":id}`. Any other error: status failed, error_message, FailJob `{"error":msg}`, return nil. Worker status writes are conditional on the locked row so a canceled import is never resurrected. csv_import_job.go ports AlbumCsvImportJob: importerCanWrite before start and per row (else canceled plus StopJob), rows not written or skipped by row_index, StartJob(count), per row inside album broadcast suppression the canonical or draft/CSV merge path, per-row failure → row error with error_code write_failed; end done, one `collection.bulk_updated` publication `{"reason":"csv_import","count":N}` on `collection:{id}` when N > 0, CompleteJob `{"written":N}`; outer failure → failed plus FailJob, return nil. jobs.go registers `conga.Job(p.matchCsv, conga.OnQueue(classes.CsvMatchQueue), conga.Timeout(240*time.Second))` and `conga.Job(p.importCsv, conga.OnQueue(classes.CsvImportQueue))`, and its doc comment now says all queued kinds have workers.
(4) Tests: TestCsvJobRows and TestCsvCancel keep their dispatch and cancel assertions but drop any assumption that the CSV queues are unserved. job_contract_worker_test.go inverts the unserved-queue assertions: the worker serves fonoteka_csv_import, fonoteka_csv_match and fonoteka_wishlist_digest, and a dispatched import for a missing id ends failed with `import_not_found`. csv_jobs_test.go `TestCsvMatchJob` (0, 1, 2 and 11 results, gate off, duplicate, cancel mid-run, superseded, a 429 beyond budget re-dispatches with the computed delay and the 240 s timeout value is asserted from the registered job) driven by a FakeClock and tide fakes; `TestCsvImportJob` (canonical overwrite and fill, create, draft wins over CSV for selected rows, write_failed row, lost access → canceled, exactly one bulk_updated publication). Record PHP's match job under the sync queue: a non-canonical mapping PATCH with the proxy running in script mode captures the job's Discogs exchanges into `parity/fixtures/jobs/csv-match.upstream.yaml`, and `php_parity.sh rows` dumps csv_import_rows and the job row into `csv-match.rows.json`; `TestCsvMatchJob/php-recorded` replays that sidecar through deliverCsvMatch and compares row statuses, candidates and draft JSON, and job metadata semantically (Pitfall 4).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestCsvWR02|TestCsvMatchJob|TestCsvImportJob|TestJobContractDispatchWhileWorkerRuns|TestCsvCommitCAS|TestCsvCancel|TestCsvJobRows)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestFonotekaNuxtFlows)$'</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 TestCsvWR02, TestCsvMatchJob, "TestCsvMatchJob/php-recorded", TestCsvImportJob and TestJobContractDispatchWhileWorkerRuns; the parity run reports FAIL for nuxt-csv or lacks "--- PASS: TestParityCorpus/coverage".</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'conga.Timeout(240 \* time.Second)' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go` prints 1.
- `grep -c 'until Phase 14' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go ../fonoteka.go/plugins/golem15/fonoteka/job_contract_worker_test.go` prints 0 for both files.
- `grep -c 'Locking' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go` prints at least 3.
- `ls ../fonoteka.go/parity/fixtures/jobs/csv-match.upstream.yaml ../fonoteka.go/parity/fixtures/jobs/csv-match.rows.json` succeeds.
</acceptance_criteria>
<done>CSV imports match and write end to end in Go with PHP's job outcomes, rate limits pause and resume the batch, and no racing write can queue a second import.</done>
</task>
<task type="auto">
<name>Task 4: Wishlist subscribers get their digest mail, and the operator prunes old notifications and rebuilds the album index from the console</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/jobs.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_digest_job.go, ../fonoteka.go/plugins/golem15/fonoteka/wishlist_digest_job_test.go, ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest.htm, ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest-en.htm, ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go, ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go, ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/schedule.go, ../fonoteka.go/plugins/golem15/fonoteka/schedule_test.go</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/WishlistDigestJob.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (lines 204-232), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest-en.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/unit/WishlistDigestJobTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/console/PruneNotifications.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/console/ReindexAlbums.php, ../fonoteka.go/plugins/golem15/fonoteka/jobs.go (sendWishlistPurchasedMail, deliverWishlistPurchasedMail locale pick), ../fonoteka.go/plugins/golem15/fonoteka/mail.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go (EnqueueWishlistDigest), ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Commands lines 267-272), ../fonoteka.go/plugins/golem15/fonoteka/schedule.go, ../fonoteka.go/plugins/golem15/fonoteka/search.go (settingsGate, wireSearch), ../fonoteka.go/plugins/golem15/fonoteka/fake_engine_test.go, ../fonoteka.go/plugins/golem15/fonoteka/schedule_test.go</read_first>
<action>Per JOBS-03, CLI-05 and SRCH-02 (Claude's discretion: straight ports).
(1) Digest: copy both PHP templates verbatim into views/mail and register `golem15.fonoteka::mail.wishlist_subscription_digest` and `-en` in mail.go. wishlist_digest_job.go `sendWishlistDigest` plus `deliverWishlistDigest(ctx, gdb, mailer, jobs, log, jobID, args)`: read the golem15_fonoteka_wishlist_digest_queue row for (subscriber, wishlist); missing or item_count ≤ 0 → CompleteJob `{"skipped":true}`; else delete the row, then mailWishlistDigest (wishlist must exist with kind wishlist and the subscriber must exist; locale en when preferred_locale is en else pl; vars ownerName, itemCount, wishlistName; to the subscriber's email), then CompleteJob `{"sent":N}`; a mail error is returned (River retries and the next run finds no row). jobs.go registers `conga.Job(p.sendWishlistDigest, conga.OnQueue(classes.WishlistDigestJobQueue))`. Tests `TestWishlistDigestJob`: missing row, zero count, sent count with the row deleted, en and pl templates with the three vars, a non-wishlist list and a missing subscriber send nothing, two dispatches for one window mail once.
(2) console/prune_notifications.go `PruneNotificationsCommand(app)` named `fonoteka:prune-notifications`: one DELETE on golem15_fonoteka_notifications where created_at is before now minus 90 days, prints `Pruned N notifications older than 90 days.`, exit 0; the cutoff comes from an unexported `pruneNotifications(ctx, db, now)` so tests pin it. console/reindex.go `ReindexCommand(app)` named `fonoteka:reindex` with Bare flag `drop-old-items-index`: not configured (engine name typesense, settingsGate enabled, engine Configured) → error line `Typesense reindex skipped: enable Fonoteka Typesense search and configure TYPESENSE_API_KEY.` and non-zero exit; any album with collection_id NULL or ≤ 0 → `Album reindex aborted: every active Album must have a positive collection_id.`; then Flush the album index, EnsureIndex from the album schema, upsert all albums ordered by id in batches of 500 through the existing searchable document; then SearchPage `q=*`, `query_by=name`, `filter_by=collection_id:=0`, `per_page=1` and `found != 0` → `Album reindex failed integrity check: collection_id:=0 documents exist.`; with the flag, DropIndex of the legacy index name (search prefix plus `golem15_fonoteka_items`) → `Deleted legacy Typesense collection: <name>` or `Legacy Typesense collection already absent: <name>`; success line `Album index rebuilt; collection_id:=0 document count is zero.` Register both in Commands() beside the oauth-client command; schedule.go's comment no longer says the command is missing.
(3) Tests console/phase14_commands_test.go: `TestPruneNotifications` (row at exactly 90 days kept, older deleted, empty run prints 0, second run deletes 0), `TestReindexCommand` (fake engine: not configured, tenantless album abort, empty table creates the index, found≠0 failure, drop present then absent, second reindex same count, only the legacy name is dropped); schedule_test.go's `TestFonotekaScheduleSkipsUnregisteredPrune` is replaced by `TestFonotekaSchedulePrune`, which asserts the daily entry now resolves to the registered command.</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/console -count=1 -race -v -run '^(TestWishlistDigestJob|TestPruneNotifications|TestReindexCommand|TestFonotekaSchedulePrune|TestJobContractDispatchWhileWorkerRuns)$' &amp;&amp; go -C ../fonoteka.go test ./... -count=1 -short</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 TestWishlistDigestJob, TestPruneNotifications, TestReindexCommand and TestFonotekaSchedulePrune; the short suite reports FAIL in any package.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'fonoteka:prune-notifications' ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go` and `grep -c 'fonoteka:reindex' ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go` each print at least 1.
- `grep -c 'PruneNotificationsCommand\|ReindexCommand' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go` prints at least 2.
- `diff /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest.htm ../fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_subscription_digest.htm` exits 0 (verbatim copy; same for -en).
- `grep -c 'Album index rebuilt; collection_id:=0 document count is zero.' ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go` prints 1.
</acceptance_criteria>
<done>The digest job mails each subscriber once per window in their language, and both console commands behave as their PHP originals including messages and exit codes.</done>
</task>
</tasks>
## Canon referrals (not minted as prohibitions)
- Token leakage through logs and errors is canon (OWASP logging) — covered by /gsd-secure-phase and the 14-01 sunscreen handler; not minted here.
- SSRF through the Discogs host is canon — the base URI is a code constant behind fetchguard AllowHostsMode; covered by /gsd-secure-phase.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Application → api.discogs.com | A user's or the site's Discogs token leaves the process |
| Concurrent processes → limiter table | serve and queue:work share one budget |
| Request writes ↔ job writes | Mapping, row edits, cancel and commit race the workers |
| Job → collection | The importer writes albums on the user's behalf |
| Job → subscriber mailbox | The digest mails outside the request path |
| Console → search engine | Reindex deletes and rebuilds indexes |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-08 | Information Disclosure | Discogs token | high | mitigate | Bucket id is an HMAC with the app key, token never stored in the limiter or job args, logs carry status and path only, sidecars masked; TestRateLimiterPostgresAcquire and check_corpus --check-secrets (Task 1). |
| T-14-09 | Denial of Service | shared Discogs budget | medium | mitigate | One atomic UNLOGGED upsert across processes, threshold 50, 15 s wait budget; TestRateLimiterBudget, TestDiscogsRateWindowConcurrent (Task 2). |
| T-14-10 | Tampering | racing CSV writes | high | mitigate | Row lock or CAS on mapping, row save and cancel (D-10); TestCsvWR02 (Task 3). |
| T-14-11 | Tampering | CSV row pick | high | mitigate | Candidate allow-list, fetch before the lock and re-check under it; TestCsvRowPickSeam, TestCsvRowPickResolves (Task 1). |
| T-14-12 | Tampering | job retries | medium | mitigate | FailJob then return nil; only timeout or panic retries; TestCsvMatchJob, TestCsvImportJob (Task 3). |
| T-14-13 | Elevation of Privilege | import writes | high | mitigate | importerCanWrite before start and per row → canceled; TestCsvImportJob lost-access case (Task 3). |
| T-14-14 | Repudiation | digest mail | medium | mitigate | Wishlist-kind and subscriber guards, row deleted before send, one mail per row; TestWishlistDigestJob (Task 4). |
| T-14-15 | Information Disclosure | reindex | high | mitigate | Abort on tenantless albums, integrity check after import, drop only the legacy index; TestReindexCommand (Task 4). |
| T-14-SC | Tampering | package installs | low | accept | No new module or package; only in-repo framework packages and stdlib. |
</threat_model>
<verification>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus still 157 ported and passing with the three new row-edit cases; check_corpus `--require-recorded --check-secrets` green.
</verification>
<success_criteria>
- Discogs client, limiter and domain classes ported with PHP vectors.
- CSV match and import workers, digest worker and both commands run as in PHP.
- WR-02 closed; row-edit pick cases recorded with sidecars and replayed offline.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-02-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,249 @@
---
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>

View File

@@ -0,0 +1,341 @@
---
phase: 14-domain-jobs-and-external-integrations
plan: 04
type: execute
wave: 4
depends_on: ["14-03"]
files_modified:
- ../fonoteka.go/plugins/golem15/golem/go.mod
- ../fonoteka.go/plugins/golem15/golem/go.sum
- ../fonoteka.go/plugins/golem15/golem/plugin.go
- ../fonoteka.go/plugins/golem15/golem/admin.go
- ../fonoteka.go/plugins/golem15/golem/README.md
- ../fonoteka.go/plugins/golem15/golem/config/config.yaml
- ../fonoteka.go/plugins/golem15/golem/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/golem/models/ai_model.go
- ../fonoteka.go/plugins/golem15/golem/models/registry.go
- ../fonoteka.go/plugins/golem15/golem/models/ai_model/fields.yaml
- ../fonoteka.go/plugins/golem15/golem/models/ai_model/columns.yaml
- ../fonoteka.go/plugins/golem15/golem/updates/registry.go
- ../fonoteka.go/plugins/golem15/golem/updates/01_ai_models.go
- ../fonoteka.go/plugins/golem15/golem/controllers/admin_registry.go
- ../fonoteka.go/plugins/golem15/golem/controllers/models_admin_controller.go
- ../fonoteka.go/plugins/golem15/golem/controllers/aimodels/config_list.yaml
- ../fonoteka.go/plugins/golem15/golem/controllers/aimodels/config_form.yaml
- ../fonoteka.go/plugins/golem15/golem/console/import_settings.go
- ../fonoteka.go/plugins/golem15/golem/classes/valueobjects/prompt.go
- ../fonoteka.go/plugins/golem15/golem/classes/valueobjects/response.go
- ../fonoteka.go/plugins/golem15/golem/classes/providers/adapter.go
- ../fonoteka.go/plugins/golem15/golem/classes/providers/openai.go
- ../fonoteka.go/plugins/golem15/golem/classes/providers/anthropic.go
- ../fonoteka.go/plugins/golem15/golem/classes/factories/prompt_factory.go
- ../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard.go
- ../fonoteka.go/plugins/golem15/golem/classes/services/model_config.go
- ../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go
- ../fonoteka.go/plugins/golem15/golem/classes/services/settings.go
- ../fonoteka.go/plugins/golem15/golem/golem_test.go
- ../fonoteka.go/plugins/golem15/golem/classes/providers/providers_test.go
- ../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard_test.go
- ../fonoteka.go/plugins/golem15/golem/classes/services/ai_service_test.go
- ../fonoteka.go/plugins/golem15/golem
- ../fonoteka.go/.gitmodules
- ../fonoteka.go/go.work
- ../fonoteka.go/go.work.sum
- ../fonoteka.go/go.mod
- ../fonoteka.go/go.sum
- ../fonoteka.go/summer.yaml
- ../fonoteka.go/plugins.gen.go
- ../fonoteka.go/main.go
- ../fonoteka.go/app/app.go
- ../fonoteka.go/plugins/golem15/fonoteka/go.mod
- ../fonoteka.go/plugins/golem15/fonoteka/go.sum
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/golem_wiring.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/recognize_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/ai_routes_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_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/
- ../fonoteka.go/parity/
autonomous: true
requirements: [INTG-02]
estimate:
tokens: 450000
raw_tokens: 450000
tasks: 4
confidence: low
must_haves:
truths:
- "Per D-01, the Go port of Golem15.Golem lives in its own repo git@git.golem15.com:golem15/sm-golem-plugin.git with module `git.golem15.com/golem15/sm-golem-plugin`, package `golem`, plugin ID `golem15.golem` and lang namespace `golem15.golem`, mounted in fonoteka.go at `plugins/golem15/golem` as a git submodule; its master is pushed before the pointer is committed in fonoteka.go, and `summer.yaml` lists golem15.golem before golem15.fonoteka, whose `Requires()` gains golem15.golem; plugins.gen.go is regenerated by `summer build`."
- "Per D-02, the Anthropic Messages and OpenAI-compatible chat-completions adapters are hand-rolled JSON over fetchguard.Client with no vendor SDK: Anthropic sends `x-api-key`, `anthropic-version: 2023-06-01`, `anthropic-beta: files-api-2025-04-14` to `<base>/messages`; OpenAI sends `Authorization: Bearer` to `<base>/chat/completions`, maps max_tokens to max_completion_tokens, strips response_format and adds `reasoning_effort: low` for o-series and gpt-5 models; both requests match PHP's recorded upstream bodies semantically."
- "Per D-04, the golem services package ports Send, SendStream, SendFile, SendFilePath, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage and Ask, the model system-prompt rule, the Prompt and AIResponse value objects and PromptFactory, with PHP's failure messages (`No AI model configured. Please add a model in Settings > AI.`, `API key is not configured for the selected model.`, `Failed to connect to AI service.`, `Invalid response from AI service: …`); faces and chat context are not ported."
- "Per D-03 and D-18, the AI models are rows of `golem15_golem_models` (name, adapter openai|anthropic, api_key lagoon.Encrypted with json:\"-\", base_url, model, system_prompt, is_enabled, is_default, generates_images, accepts_images, has_files_endpoint, max_completion_tokens, sort_order) edited through a cabana list and form under permission `golem15.golem.access_settings`; `golem:import-settings` imports the PHP `system_settings` row `golem_settings` (plaintext keys encrypted on write) once and refuses to import into a non-empty table; DefaultModel keeps PHP's key-preserving quirk (model 0 disabled → no default fallback)."
- "Per D-20 and D-05, `security.AssertSafeURL` ports SSRFGuard exactly (https only, host allowlist default `oaidalleapiprodscus.blob.core.windows.net` and `.openai.com` from `golem15.golem.ssrf.allowed_hosts`, overridden by `GOLEM15_SSRF_ALLOWED_HOSTS`, resolve-time private-IP check) and applies only to user and org credential base_url overrides and inline test base_urls; a guard failure answers Winter's 500 HTML page; user and org calls also run in fetchguard PublicOnlyMode, while admin Settings models run in TrustedMode."
- "Per D-03, fonoteka's `classes.AIConfig` gains `Trusted bool` (json:\"-\") and `classes.SetAdminVisionModel` is installed at Boot from the golem VisionModel (first enabled model accepting images, Trusted true), so the admin tier of ResolveAIConfig and the site-admin branch of AIAllowed use the backend global vision model."
- "Per INTG-02 and D-07, `POST ai-credential/test` and `POST albums/recognize` on the JWT group and `POST /api/v1/fonoteka/albums/recognize` on the token group (`inv.scope:ai`) pass the parity diff with upstream sidecars: recognize checks AiGate (403 `{\"error\":\"AI features not available\"}`), the `fonoteka-recognize:` limiter (10 per 60 s, 429 `{\"error\":\"Too many requests\"}`), validation, then the image guard; retries once with the raw-JSON instruction; a truncated second answer returns 200 `{\"albums\":[],\"code\":\"recognition_truncated\"}`; `expectedPortedRoutes` is 168 with only the three D-09 routes pending."
artifacts:
- path: "../fonoteka.go/plugins/golem15/golem/plugin.go"
provides: "golem.Plugin (golem15.golem)"
contains: "golem15.golem"
- path: "../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go"
provides: "AIService with Send, SendStream, SendFile, SendFilePath, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask"
contains: "func (s *AIService) Send("
- path: "../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard.go"
provides: "AssertSafeURL, AllowedHosts, UnsafeURLError"
contains: "GOLEM15_SSRF_ALLOWED_HOSTS"
- path: "../fonoteka.go/plugins/golem15/golem/models/ai_model.go"
provides: "AiModel (golem15_golem_models)"
contains: "lagoon.Encrypted"
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition.go"
provides: "RecognizeAlbums, ErrRecognitionTruncated, RecognizedAlbum"
key_links:
- from: "../fonoteka.go/plugins/golem15/fonoteka/golem_wiring.go"
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go"
via: "Boot calls classes.SetAdminVisionModel with golem's VisionModel mapped to AIConfig{Trusted: true}"
pattern: "SetAdminVisionModel"
- from: "../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go"
to: "summercms.go modules/fetchguard/client.go"
via: "TrustedMode for admin models, PublicOnlyMode for user/org configs"
pattern: "fetchguard.TrustedMode"
- from: "../fonoteka.go/summer.yaml"
to: "../fonoteka.go/plugins.gen.go"
via: "summer build emits the blank import of git.golem15.com/golem15/sm-golem-plugin"
pattern: "sm-golem-plugin"
prohibitions:
- requirement_id: INTG-02
category: privacy
statement: "A user's photo and credential MUST NOT be sent to a provider other than the one the resolved tier names: a user or org call never falls back to the admin global model, and an admin call never uses a user's key"
status: resolved
verification: test
- requirement_id: INTG-02
category: transparency
statement: "Recognition MUST NOT invent albums: when both AI answers are unparseable it returns [] (or recognition_truncated), never a guessed list"
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 photographs a shelf and gets the albums recognised through their own Anthropic or OpenAI-compatible key, the org key or the site's global vision model; they can test a key first; operators manage the global AI models in the admin (INTG-02; ROADMAP SC5).
<objective>
Create the shared core plugin sm-golem-plugin (AI models table, admin screens, settings importer, provider adapters, AIService, SSRF guard), mount it in fonoteka.go, light up the admin AI tier, and port AlbumRecognitionService with the recognize routes on both groups and `ai-credential/test`, recorded with upstream sidecars.
Purpose: AI recognition is a headline Nuxt and MCP feature; other Golem15 apps reuse the plugin. Decisions: D-01, D-02, D-03, D-04, D-05, D-18, D-20; research Pitfalls 1-3, 8, 9.
Output: the sm-golem-plugin repo and submodule, fonoteka wiring, recognition service, three routes; ported 168, pending 3.
Repos: sm-golem-plugin (new; commit and push its master first), then fonoteka.go (pointer bump as its own commit, then wiring and features). Never stage submodule files from the app repo; `ssu` may be used for submodule status and push. 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-PATTERNS.md
@.planning/notes/core-plugins-own-repos.md
@.planning/notes/plugin-layout-winter-directories.md
<interfaces>
- From 14-01: `fetchguard.NewClient`, `TrustedMode`, `PublicOnlyMode`, `(*Client).PostJSON/PostMultipart/Do`, `WithTransport`; `tide.LoadUpstream/NewUpstreamFake`; `sunscreen` installed in generated mains.
- From 14-03: `api.InboundLimits` (add `Recognize`), `phase14Routes`, `phase14Absent` (only `POST /ai-credential/test` left).
- fonoteka today: `classes.AIConfig{Adapter, APIKey, BaseURL, Model}`, `AdminVisionModel` package var (assigned in credentials_smoke_test.go:486-488), `ResolveAIConfig`, `aiConfigFrom`, `AIAllowed`, `VisionModelFor`, `ClaudeVisionModel`, `OpenAIVisionModel`, `ErrNoAICredential`; `classes.IsAllowedImage`, `SniffImageMIME`; `classes.ParseTracklistText`; `api.WriteWinterHTTPError`; Plugin `Requires() []string{"golem15.user"}`; app/app.go `PluginIDs` and blank imports.
- User plugin precedents: plugins/golem15/user/{go.mod (replace to ../../../../summercms.go), plugin.go (assertions, embeds, init registration), README.md}; fonoteka admin precedents: admin.go (AdminFS, AdminControllers), controllers/genres_admin_controller.go, controllers/genres/config_list.yaml, models/user_ai_credential.go (lagoon.Encrypted, Hidden), models/registry.go, updates/registry.go.
- PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/{Plugin.php, models/Settings.php, models/settings/fields.yaml, config/ssrf.php, lang/en/lang.php, classes/services/AIService.php, classes/providers/*.php, classes/security/SSRFGuard.php, classes/valueobjects/{Prompt,AIResponse}.php, classes/factories/PromptFactory.php, tests/unit/OpenAIAdapterTest.php, tests/security/SSRFGuardTest.php}; fonoteka {classes/AlbumRecognitionService.php, classes/RecognitionTruncatedException.php, classes/UserAiConfig.php, classes/OrgAiConfig.php, classes/AiGate.php, controllers/api/RecognizeApiController.php, controllers/api/AiCredentialController.php, tests/unit/AlbumRecognitionServiceTest.php}; MCP fonoteka-mcp/src/client.ts (recognize multipart).
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- Repo `git@git.golem15.com:golem15/sm-golem-plugin.git` (master), module `git.golem15.com/golem15/sm-golem-plugin`, submodule `plugins/golem15/golem`.
- Package `golem`: `Plugin` (ID `golem15.golem`), `AdminFS`, `AdminControllers`, `Permissions` (`golem15.golem.access_settings`), `Navigation`, `Commands`.
- `models`: `AiModel` (table `golem15_golem_models`), `Register`, `All`. Migration `202610030101_create_golem_models`.
- `classes/valueobjects`: `Prompt`, `Message`, `ContentPart`, `AIResponse`, `Usage`. `classes/providers`: `Adapter` interface, `OpenAIAdapter`, `AnthropicAdapter`, `ForName`. `classes/factories`: `PromptFactory`. `classes/security`: `AssertSafeURL`, `AllowedHosts`, `UnsafeURLError`. `classes/services`: `ModelConfig`, `AIService`, `NewAIService`, `Send`, `SendStream`, `SendFile`, `SendFilePath`, `SendWithModel`, `SendToImageModel`, `SendToVisionModel`, `GenerateImage`, `Ask`, `Models`, `DefaultModel`, `VisionModel`, `ImageModel`, `ModelByName`, `FileModel`.
- Console: `golem:import-settings` (`--file`).
- Config keys: `golem15.golem.ssrf.allowed_hosts`, `golem15.golem.ai.timeout_seconds` (120); env `GOLEM15_SSRF_ALLOWED_HOSTS`.
- fonoteka: `AIConfig.Trusted`, `SetAdminVisionModel`, `AdminVisionModel` (func), `RecognizeAlbums`, `RecognizedAlbum`, `ErrRecognitionTruncated`, `api.AlbumRecognize`, `api.AICredentialTest`, `InboundLimits.Recognize`; routes JWT `POST /albums/recognize`, `POST /ai-credential/test`, token `POST /albums/recognize` (`inv.scope:ai`).
- Tests: `TestOpenAIAdapterPayload`, `TestAnthropicAdapterPayload`, `TestAIServiceFailures`, `TestAIServiceStream`, `TestDefaultModelQuirk`, `TestImportSettings`, `TestSSRFGuard`, `TestAdminModelTrusted`, `TestAICredentialTestRoute`, `TestRecognizeRoutes`, `TestRecognizeAlbums`, `TestGolemPluginBoot`.
## Assumptions
- EDGE-UNCLASSIFIED (INTG-02, flagged): the edge probe could not classify the AI requirement; this plan assumes recognition's limits are PHP's constants (MAX_ALBUMS 30, MAX_COMPLETION_TOKENS 8192, year 1889-2100, formats from Album::FORMATS) and does not invent further edges. Not auto-resolved.
- Research Open Question 1 stays open: whether production sets `GOLEM15_SSRF_ALLOWED_HOSTS`. The plugin keeps PHP's default and honours the variable; the production value is an operator setting confirmed at cutover (Phase 15).
- D-04 scope: PHP's Conversations/Messages admin controller and chat components belong to the not-ported chat surface (with ChatContextCollector), so no conversation tables are created.
- A4: AI calls time out after 120 s (`golem15.golem.ai.timeout_seconds`).
- A2: cloning the empty remote and pushing a first commit, then `git submodule add` of the existing checkout, is the bootstrap path.
<tasks>
<task type="tracer">
<name>Task 1: A collector tests an inline OpenAI key and gets PHP's answer through the new sm-golem-plugin, replayed offline from PHP's recorded exchange</name>
<reversibility rating="costly">Pushing sm-golem-plugin master fixes the module path and plugin ID other applications mount (D-01, user-decided); re-pushing is possible but every consumer would follow.</reversibility>
<precondition>`git ls-remote git@git.golem15.com:golem15/sm-golem-plugin.git` exits 0 (the repo exists and is reachable), and the isolated PHP instance plus `summer parity:upstream` work as in 14-02.</precondition>
<files>../fonoteka.go/plugins/golem15/golem/go.mod, ../fonoteka.go/plugins/golem15/golem/go.sum, ../fonoteka.go/plugins/golem15/golem/plugin.go, ../fonoteka.go/plugins/golem15/golem/README.md, ../fonoteka.go/plugins/golem15/golem/config/config.yaml, ../fonoteka.go/plugins/golem15/golem/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/golem/classes/valueobjects/prompt.go, ../fonoteka.go/plugins/golem15/golem/classes/valueobjects/response.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/adapter.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/openai.go, ../fonoteka.go/plugins/golem15/golem/classes/services/model_config.go, ../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/providers_test.go, ../fonoteka.go/plugins/golem15/golem/golem_test.go, ../fonoteka.go/.gitmodules, ../fonoteka.go/go.work, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/summer.yaml, ../fonoteka.go/plugins.gen.go, ../fonoteka.go/main.go, ../fonoteka.go/app/app.go, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/ai_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/, ../fonoteka.go/parity/</files>
<read_first>.planning/notes/core-plugins-own-repos.md, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/user/plugin.go (assertions, embeds, init), ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/go.work, ../fonoteka.go/go.mod (replace lines), ../fonoteka.go/summer.yaml, ../fonoteka.go/.gitmodules, ../fonoteka.go/app/app.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (ID, Requires), /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/services/AIService.php (send), /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/providers/OpenAIAdapter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/valueobjects/Prompt.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/valueobjects/AIResponse.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/tests/unit/OpenAIAdapterTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AiCredentialController.php (test, resolveTestConfig), ../fonoteka.go/parity/manifest.yaml (the pending `POST /_fonoteka/api/v1/ai-credential/test jwt` entry and its fixture)</read_first>
<action>Per D-01, D-02, D-04, D-11, D-15, D-19.
(1) Repo bootstrap: clone git@git.golem15.com:golem15/sm-golem-plugin.git into ../fonoteka.go/plugins/golem15/golem (an empty clone), write `go.mod` (`module git.golem15.com/golem15/sm-golem-plugin`, go 1.27.0, require git.golem15.com/golem15/summercms v0.0.0 with `replace … => ../../../../summercms.go`), plugin.go (package `golem`, `Plugin` with ID `golem15.golem`, Requires nil, Register, Boot, ConfigFS, LangFS, interface assertions as the user plugin, init registration), config/config.yaml (`ssrf.allowed_hosts` with PHP's two defaults, `ai.timeout_seconds: 120`), lang/en/lang.yaml, and a README with the standard structure that never names a consuming application. `go mod tidy` with GOWORK=off; commit on master in the plugin checkout and push master to origin. Then in fonoteka.go run `git submodule add git@git.golem15.com:golem15/sm-golem-plugin.git plugins/golem15/golem` (adds the existing checkout) and commit `.gitmodules` plus the gitlink alone.
(2) golem path the inline test travels (Task 2 completes the rest of D-04): valueobjects `Prompt` (messages, system, options, model) and `AIResponse` (success, content, usage, raw, error); providers `Adapter` interface (Headers, ChatEndpoint, FilesEndpoint, ImageEndpoint, BuildChatPayload, FileUploadFields, FileUploadHeaders, ParseChatResponse, ParseStreamChunk) and `OpenAIAdapter` ported from PHP; services `ModelConfig{Name, Adapter, APIKey, BaseURL, Model, SystemPrompt string; MaxCompletionTokens int; HasFilesEndpoint, Trusted bool}` (APIKey json:"-") and `AIService.Send(ctx, prompt, *ModelConfig)` with PHP's failure messages, Content-Type first then adapter headers, through fetchguard.NewClient in TrustedMode when cfg.Trusted else PublicOnlyMode, timeout from config, status code ignored as PHP.
(3) fonoteka wiring: go.work `use ./plugins/golem15/golem`; app go.mod and plugin go.mod require `git.golem15.com/golem15/sm-golem-plugin v0.0.0` with replace to `./plugins/golem15/golem` and `../golem`; summer.yaml lists `golem15.golem` (module git.golem15.com/golem15/sm-golem-plugin) before golem15.fonoteka; fonoteka Requires returns golem15.user and golem15.golem; app/app.go PluginIDs and blank import; every test or harness activation list naming golem15.fonoteka also names golem15.golem; regenerate plugins.gen.go and main.go by building summer from summercms.go and running its build subcommand in fonoteka.go (never hand-edit).
(4) `POST /ai-credential/test` inline path: `api.AICredentialTest(app)` ports AiCredentialController::test for an inline `{provider, api_key, model}` body (OpenAI here; Task 3 adds the rest), sends PHP's test prompt through golem Send and answers `{"ok":true}` or `{"ok":false,"error":<provider message>}`. Mount on the JWT group; move it from phase14Absent into phase14Routes; delete phase14Absent and its two subtests once empty. Re-record the existing case through the proxy with a script file for OpenAI `POST /v1/chat/completions` 401 carrying the masked-key provider message; flip the route; expectedPortedRoutes 166.
(5) Tests: providers_test.go `TestOpenAIAdapterPayload` (OpenAIAdapterTest vectors); golem_test.go `TestGolemPluginBoot` (activates with golem15.user absent, ID and lang namespace); ai_routes_test.go `TestAICredentialTestRoute` (inline OpenAI path through a tide fake).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/golem ./plugins/golem15/golem/classes/providers -count=1 -race -v -run '^(TestOpenAIAdapterPayload|TestGolemPluginBoot)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestAICredentialTestRoute|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 &amp;&amp; git -C ../fonoteka.go/plugins/golem15/golem status --porcelain --branch</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 TestOpenAIAdapterPayload, TestGolemPluginBoot, TestAICredentialTestRoute and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or mismatch; the submodule status line shows "ahead" or any modified file.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'path = plugins/golem15/golem' ../fonoteka.go/.gitmodules` prints 1 and `git -C ../fonoteka.go/plugins/golem15/golem rev-parse origin/master` exits 0.
- `grep -c 'module git.golem15.com/golem15/sm-golem-plugin' ../fonoteka.go/plugins/golem15/golem/go.mod` prints 1.
- `grep -n 'golem15.golem' ../fonoteka.go/summer.yaml` shows a line number lower than the `golem15.fonoteka` line.
- `grep -c 'sm-golem-plugin' ../fonoteka.go/plugins.gen.go` prints 1 and the file still starts with `// Code generated by summer build. DO NOT EDIT.`
- `grep -c 'golem15.golem' ../fonoteka.go/plugins/golem15/fonoteka/plugin.go` prints at least 1.
</acceptance_criteria>
<done>The new core plugin exists, is pushed and mounted, and one AI call travels from the Nuxt route through golem's OpenAI adapter and the guarded client to a PHP-recorded exchange.</done>
</task>
<task type="auto">
<name>Task 2: Operators manage global AI models in the admin, migrated PHP settings import once, and every AIService call and both adapters behave as PHP</name>
<reversibility rating="costly">`golem15_golem_models` becomes the plugin's table contract (D-18, user-decided).</reversibility>
<files>../fonoteka.go/plugins/golem15/golem/plugin.go, ../fonoteka.go/plugins/golem15/golem/admin.go, ../fonoteka.go/plugins/golem15/golem/README.md, ../fonoteka.go/plugins/golem15/golem/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/golem/models/ai_model.go, ../fonoteka.go/plugins/golem15/golem/models/registry.go, ../fonoteka.go/plugins/golem15/golem/models/ai_model/fields.yaml, ../fonoteka.go/plugins/golem15/golem/models/ai_model/columns.yaml, ../fonoteka.go/plugins/golem15/golem/updates/registry.go, ../fonoteka.go/plugins/golem15/golem/updates/01_ai_models.go, ../fonoteka.go/plugins/golem15/golem/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/golem/controllers/models_admin_controller.go, ../fonoteka.go/plugins/golem15/golem/controllers/aimodels/config_list.yaml, ../fonoteka.go/plugins/golem15/golem/controllers/aimodels/config_form.yaml, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/anthropic.go, ../fonoteka.go/plugins/golem15/golem/classes/factories/prompt_factory.go, ../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard.go, ../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go, ../fonoteka.go/plugins/golem15/golem/classes/services/settings.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/providers_test.go, ../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard_test.go, ../fonoteka.go/plugins/golem15/golem/classes/services/ai_service_test.go, ../fonoteka.go/plugins/golem15/golem/golem_test.go, ../fonoteka.go/plugins/golem15/golem</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/models/Settings.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/models/settings/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/Plugin.php (permissions, settings), /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/services/AIService.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/providers/AnthropicAdapter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/factories/PromptFactory.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/classes/security/SSRFGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/config/ssrf.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/tests/security/SSRFGuardTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/golem/tests/security/AIServiceLogRedactionTest.php, ../fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, ../fonoteka.go/plugins/golem15/fonoteka/models/registry.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, summercms.go modules/cabana/form_schema.go (supported field types), summercms.go modules/fetchguard/ip.go</read_first>
<action>Per D-03, D-04, D-05, D-18, D-20. All inside the plugin checkout; commit and push master there, then bump the pointer in fonoteka.go as its own commit.
(1) Storage and admin: models/ai_model.go `AiModel` (columns listed in the must-haves; `api_key` lagoon.Encrypted with json:"-" and Hidden; Fillable excludes id and timestamps; Rules: name required, adapter in openai,anthropic, max_completion_tokens integer); migration ID `202610030101_create_golem_models` creating `golem15_golem_models`; controllers `golem15.golem.models` (ModelName `Golem15\Golem\Models\AiModel`, ConfigDir `controllers/aimodels`, permission `golem15.golem.access_settings`) with config_list.yaml and config_form.yaml, and fields.yaml/columns.yaml using only field types cabana accepts (api_key a write-only password-style text field that is never echoed back); Navigation item `golem` with side menu `models`; Permissions `golem15.golem.access_settings`. classes/services/settings.go ports Settings: `Models(ctx, db)` (enabled rows by sort_order, keeping PHP's array_filter key positions), `DefaultModel` (first is_default, else position 0 only if that model is enabled — the quirk), `VisionModel` (first enabled accepts_images), `ImageModel`, `ModelByName`, `FileModel`; each returns `*ModelConfig` with Trusted true. console/import_settings.go `golem:import-settings`: reads `system_settings` item `golem_settings` when that table exists (or `--file <json>`), refuses when `golem15_golem_models` already has rows, inserts each repeater item in order with sort_order and encrypts its plaintext key; prints the count.
(2) Adapters and service: providers/anthropic.go ports AnthropicAdapter (headers, `<base>/messages`, payload with `max_tokens: options.max_tokens ?? options.max_completion_tokens ?? 4096`, image blocks base64 or url, document file_id blocks, error.message → failure, content[0].text, usage mapping, raw kept for stop_reason); `ForName(adapter)` factory. ai_service.go completes D-04: `SendStream(ctx, prompt, onChunk, cfg)` (stream true, `Accept: text/event-stream`, line-buffered SSE through `(*fetchguard.Client).Do`, adapter ParseStreamChunk), `SendFile`/`SendFilePath` (files endpoint multipart, has_files_endpoint required), `SendWithModel(name)`, `SendToImageModel`, `SendToVisionModel`, `GenerateImage` (OpenAI images endpoint; the returned URL must pass AssertSafeURL before anyone fetches it), `Ask`, and applyModelSystemPrompt (model system_prompt only when the prompt has none); exceptions surface PHP's safe message ("Internal server error" outside debug). factories/prompt_factory.go ports PromptFactory.
(3) security/ssrf_guard.go `AssertSafeURL(ctx, rawURL string, allowed []string) error` and `AllowedHosts(cfg)` (env `GOLEM15_SSRF_ALLOWED_HOSTS` comma list when non-empty, else config `golem15.golem.ssrf.allowed_hosts`): parse with scheme and host or `Invalid URL`; https only (`Only https:// scheme allowed`); leading-dot suffix or exact case-insensitive host match (`Host not in allowlist: <host>`); resolve A/AAAA (`Cannot resolve host`) and reject private, loopback and reserved results using fetchguard's classification (`Resolves to private/loopback IP`); errors are `*UnsafeURLError`.
(4) Tests (`TestImportSettings` in golem_test.go; adapter tests in classes/providers; `TestSSRFGuard` in classes/security; the service, quirk and trust tests in classes/services/ai_service_test.go): `TestAnthropicAdapterPayload`, `TestAIServiceFailures` (each PHP failure message, invalid JSON keeps raw, a file upload refused without has_files_endpoint, a SendFilePath multipart upload to the files endpoint, and GenerateImage's request to the images endpoint with its returned URL checked by AssertSafeURL), `TestAIServiceStream` (chunks delivered in order from an SSE fake), `TestDefaultModelQuirk` (model 0 disabled and model 1 enabled without is_default → nil), `TestImportSettings` (plaintext key ends encrypted, second run refused, missing system_settings is a clean message), `TestSSRFGuard` (SSRFGuardTest vectors plus the env override), `TestAdminModelTrusted` (an admin ModelConfig reaches an http loopback endpoint; a user ModelConfig to the same endpoint fails private_ip). README documents config keys, the env variable, the command and the admin screen; neutral names only.</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/golem ./plugins/golem15/golem/classes/providers ./plugins/golem15/golem/classes/security ./plugins/golem15/golem/classes/services -count=1 -race -v -run '^(TestAnthropicAdapterPayload|TestOpenAIAdapterPayload|TestAIServiceFailures|TestAIServiceStream|TestDefaultModelQuirk|TestImportSettings|TestSSRFGuard|TestAdminModelTrusted|TestGolemPluginBoot)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/golem/... -count=1 &amp;&amp; git -C ../fonoteka.go/plugins/golem15/golem status --porcelain --branch</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 any of the nine named tests; the submodule status shows "ahead" or a modified file.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'golem15_golem_models' ../fonoteka.go/plugins/golem15/golem/updates/01_ai_models.go` prints at least 1 and `grep -c 'json:"-"' ../fonoteka.go/plugins/golem15/golem/models/ai_model.go` prints at least 1.
- `grep -c 'oaidalleapiprodscus.blob.core.windows.net' ../fonoteka.go/plugins/golem15/golem/config/config.yaml` prints 1.
- `grep -rlE 'anthropic-sdk|openai-go|sashabaranov' ../fonoteka.go/plugins/golem15/golem/go.mod` prints nothing.
- `grep -c 'golem:import-settings' ../fonoteka.go/plugins/golem15/golem/README.md` prints at least 1 and the README names no consuming application.
- The fonoteka.go commit that bumps the pointer changes only the gitlink.
</acceptance_criteria>
<done>sm-golem-plugin carries the full AI layer PHP's Golem had (minus faces and chat), its admin model list, the one-time importer and the exact SSRF guard, pushed and pinned in the application.</done>
</task>
<task type="auto">
<name>Task 3: A collector's own or org key, or the site's global vision model, is chosen exactly as PHP chooses it, and unsafe base URLs get PHP's 500 page</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go, ../fonoteka.go/plugins/golem15/fonoteka/golem_wiring.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/ai_routes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../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>../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go (whole file), ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (AIAllowed), ../fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go (lines 480-500), ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go (SetReleaseFetcher atomic-box pattern), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/UserAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAiConfig.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiConfigResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AiCredentialController.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/unit/{AiConfigResolverTest,AiGateVisionModelTest}.php, ../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard.go, ../fonoteka.go/plugins/golem15/golem/classes/services/settings.go</read_first>
<action>Per D-03, D-05, D-20.
(1) classes/ai_config_resolver.go: AIConfig gains `Trusted bool` with json:"-" (set only by the admin tier); the package var becomes `func AdminVisionModel(ctx) (*AIConfig, error)` backed by an atomic box with `SetAdminVisionModel(f func(context.Context) (*AIConfig, error)) (restore func())` (nil restores the no-model default); credentials_smoke_test.go switches to the setter. aiConfigFrom for user and org credentials calls golem `security.AssertSafeURL` when a base_url override is present, using `security.AllowedHosts(cfg)`, and returns the `*UnsafeURLError` unchanged (PHP throws at resolve time).
(2) golem_wiring.go (root): Boot installs SetAdminVisionModel with a function that resolves the db per call and maps golem's `VisionModel` to `AIConfig{Adapter, APIKey, BaseURL, Model, Trusted: true}`; `aiModelConfig(*classes.AIConfig) *services.ModelConfig` maps back for calls (Trusted carried through). gates.go's site-admin branch is unchanged code but now sees a real model.
(3) AICredentialTest completes AiCredentialController::test: inline `{provider: claude|openai, api_key, model, base_url}` (inline base_url through AssertSafeURL), the stored user or org credential through ResolveAIConfig, `{"ok":false,"error":"No AI credential configured."}` when nothing resolves; an `*UnsafeURLError` from either path writes `api.WriteWinterHTTPError(w, app, 500)` (PHP's uncaught RuntimeException), never `{ok:false}`.
(4) Parity: script files for Anthropic `POST /v1/messages` 200; record the research D-11 case 16 rows: inline claude ok, stored credential ok, unsafe base_url (`https://127.0.0.1/v1`) → 500 page with no upstream, non-allowlisted host (`https://api.groq.com/openai/v1`) → 500 page, no credential; seed a stored BYOK AI credential `{{secret:ai-key}}` (sk-parity-…) on both sides. Tests ai_routes_test.go: `TestAICredentialTestRoute` gains the stored, unsafe, non-allowlisted and no-credential cases; `TestAdminVisionTier` (a site admin with no own key resolves the golem vision model with Trusted true; a normal user never gets it; a user with an org lock never falls back to the admin model).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestAICredentialTestRoute|TestAdminVisionTier|TestResolveAIConfigPrecedence)$' &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 TestAICredentialTestRoute, TestAdminVisionTier, TestResolveAIConfigPrecedence and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or case-status mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'func SetAdminVisionModel(' ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go` prints 1 and `grep -c 'Trusted bool' ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go` prints 1.
- `grep -rn 'classes.AdminVisionModel =' ../fonoteka.go/plugins/golem15/fonoteka` prints nothing.
- The ai-credential/test manifest entry carries the unsafe and non-allowlisted cases, both recorded as Winter 500 HTML with no sidecar exchange.
</acceptance_criteria>
<done>The AI resolver picks the same tier PHP picks, admin models run trusted and user or org overrides run guarded, and unsafe base URLs fail with PHP's page.</done>
</task>
<task type="auto">
<name>Task 4: A collector photographs albums in the Nuxt app or through MCP and gets PHP's recognised album list, including the truncation answer</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/recognize_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/ai_routes_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../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/classes/AlbumRecognitionService.php (whole file, system prompt lines 237-292), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/RecognitionTruncatedException.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/RecognizeApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AiGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/unit/AlbumRecognitionServiceTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 244, 472), /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts (recognize call), ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go (multipart intake), ../fonoteka.go/plugins/golem15/fonoteka/models/album.go (FORMATS), ../fonoteka.go/parity/manifest.yaml (the two pending recognize entries; the JWT one says status 422 while its fixture is 403)</read_first>
<action>Per D-07, D-11, D-15, INTG-02.
(1) classes/album_recognition.go `RecognizeAlbums(ctx, db, ai *services.AIService, cfg *classes.AIConfig, collectionID uint, image []byte, mime, locale string) ([]RecognizedAlbum, error)` ports AlbumRecognitionService: MAX_ALBUMS 30, MAX_COMPLETION_TOKENS 8192; the admin path with no config and no vision model returns `No vision model configured in Golem AI settings.`; the system prompt text copied verbatim with the format list, the genre hint (up to 20 distinct genres of albums in the collection, newest first) and the language directive (en or pl from the BCP-47 locale); the user message carries the image as a data URL; strip code fences and decode; on failure retry once with PHP's appended raw-JSON instruction; a retry failure whose finish reason is `length` or `max_tokens` returns `ErrRecognitionTruncated`, otherwise an empty list; normalisation drops nameless rows, nulls empty strings, keeps years 1889-2100, keeps only Album formats and parses tracklists with ParseTracklistText; `RecognizedAlbum` marshals in PHP key order.
(2) controllers/api/recognize_controller.go `AlbumRecognize(app, limits)` shared by both groups: requestScope; AIAllowed false → 403 `{"error":"AI features not available"}`; `InboundLimits.Recognize` (`fonoteka-recognize:` plus user id, 10 per 60 s) → 429 `{"error":"Too many requests"}`; validation of `photo` (and `locale`) as PHP → 422 body; IsAllowedImage false → PHP's 422; ResolveAIConfig (an `*UnsafeURLError` → Winter 500 page); RecognizeAlbums → 200 `{"albums":[…]}`, truncation → 200 `{"albums":[],"code":"recognition_truncated"}`, provider failure → 502 as PHP. routes.go mounts JWT `POST /albums/recognize` and token `POST /albums/recognize` with `inv.scope:ai`; add both to phase14Routes.
(3) Parity: fix the JWT recognize manifest status to the fixture's 403 (Pitfall 9); script files for OpenAI and Anthropic recognize answers (valid JSON list, two non-JSON answers with `finish_reason: length` / `stop_reason: max_tokens`, two unparseable answers not truncated, a provider error); record research case 17 rows (BYOK success, truncated, empty, 502, non-image 422, admin-tier with the global vision model seeded in golem15_golem_models on both sides) and case 18 (token multipart with `photo` filename `cover` and `locale`); flip both routes; expectedPortedRoutes 168, pending 3 (the D-09 routes). README notes the AI script files.
(4) Tests: album_recognition_test.go `TestRecognizeAlbums` (AlbumRecognitionServiceTest vectors: caps, year bounds, format filter, fence stripping, retry then truncation, retry then empty); ai_routes_test.go `TestRecognizeRoutes` (gate before limiter before validation, the 11th call is 429, a spoofed image is 422 with no upstream call, token route needs `ai` scope).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes -count=1 -race -v -run '^(TestRecognizeAlbums|TestRecognizeRoutes|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 &amp;&amp; go -C ../fonoteka.go test ./... -count=1 -short</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 TestRecognizeAlbums, TestRecognizeRoutes, TestRouteTablePhase14 and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or mismatch; the short suite reports FAIL.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 168.
- `grep -c 'var phase14Absent' ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase13_test.go` prints 0.
- `grep -c 'inv.scope:ai' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints at least 1 on the recognize line.
- `grep -c 'recognition_truncated' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/recognize_controller.go` prints at least 1.
</acceptance_criteria>
<done>Recognition works on both groups with PHP's answers for success, truncation, empty and errors, and every Phase 14 fonoteka route is ported except the three D-09 routes.</done>
</task>
</tasks>
## Canon referrals (not minted as prohibitions)
- SSRF through AI base URLs is canon (OWASP SSRF) — handled by the D-20 guard truth and /gsd-secure-phase.
- API keys at rest and in logs are canon (OWASP cryptographic storage, logging) — lagoon.Encrypted, json:"-" and sunscreen; /gsd-secure-phase.
- Uploaded-file type spoofing is canon (OWASP file upload) — the image guard; /gsd-secure-phase.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| User/org credential base_url → outbound call | User input chooses where a request and an API key go |
| Admin Settings model → outbound call | Operator-configured endpoints may be on the LAN |
| Photo upload → AI provider | User images leave the system |
| AI answer → album data | Untrusted model output is turned into records |
| Plugin repo → remote | A new public-to-the-team repository is pushed |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-22 | Tampering | user/org base_url | high | mitigate | AssertSafeURL (https, allowlist, resolve-time private check) plus fetchguard PublicOnlyMode dial guard; Winter 500 on failure; TestSSRFGuard, TestAICredentialTestRoute unsafe cases (Tasks 2-3). |
| T-14-23 | Elevation of Privilege | trusted mode | high | mitigate | Trusted set only by the admin-tier wiring in Go (json:"-"); TestAdminModelTrusted, TestAdminVisionTier (Tasks 2-3). |
| T-14-24 | Information Disclosure | AI api keys | high | mitigate | lagoon.Encrypted, json:"-", Hidden, write-only admin field, importer encrypts plaintext; TestImportSettings (Task 2). |
| T-14-25 | Tampering | model output | medium | mitigate | PHP system-prompt rule that photo text is data, MAX_ALBUMS, year and format caps; TestRecognizeAlbums (Task 4). |
| T-14-26 | Denial of Service | recognize | medium | mitigate | AiGate, fonoteka-recognize 10/60 s, image guard before any upstream call; TestRecognizeRoutes (Task 4). |
| T-14-27 | Tampering | photo uploads | medium | mitigate | IsAllowedImage sniff and decode check; TestRecognizeRoutes spoofed-image case (Task 4). |
| T-14-28 | Information Disclosure | provider error echo | low | accept | PHP returns the provider's error text verbatim (the provider masks keys, e.g. `sk-parit******real`); parity requires it; logs are scrubbed by sunscreen. |
| T-14-29 | Information Disclosure | sm-golem-plugin push | low | mitigate | The repo holds code, config defaults and docs only; no key, token or DSN literal is committed (checked before the push in Task 1). |
| T-14-SC | Tampering | package installs | low | accept | No vendor SDK or new module (D-02); only the in-house sm-golem-plugin module. |
</threat_model>
<verification>
- sm-golem-plugin: master pushed, `go vet` and `go test` green inside the application workspace.
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; corpus 168 ported, 0 failing, 3 pending; check_corpus `--require-recorded --check-secrets` green.
</verification>
<success_criteria>
- sm-golem-plugin exists as a shared core plugin with models, admin, importer, adapters, AIService and SSRF guard.
- The admin AI tier uses the global vision model; user and org calls are guarded.
- Recognize (both groups) and ai-credential/test pass the parity diff offline.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-04-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,305 @@
---
phase: 14-domain-jobs-and-external-integrations
plan: 05
type: execute
wave: 5
depends_on: ["14-04"]
files_modified:
- ../fonoteka.go/plugins/golem15/feedback/go.mod
- ../fonoteka.go/plugins/golem15/feedback/go.sum
- ../fonoteka.go/plugins/golem15/feedback/plugin.go
- ../fonoteka.go/plugins/golem15/feedback/routes.go
- ../fonoteka.go/plugins/golem15/feedback/admin.go
- ../fonoteka.go/plugins/golem15/feedback/jobs.go
- ../fonoteka.go/plugins/golem15/feedback/README.md
- ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js
- ../fonoteka.go/plugins/golem15/feedback/config/config.yaml
- ../fonoteka.go/plugins/golem15/feedback/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/feedback/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/feedback/models/submission.go
- ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go
- ../fonoteka.go/plugins/golem15/feedback/models/settings.go
- ../fonoteka.go/plugins/golem15/feedback/models/registry.go
- ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml
- ../fonoteka.go/plugins/golem15/feedback/models/submission/columns.yaml
- ../fonoteka.go/plugins/golem15/feedback/models/submission/fields.yaml
- ../fonoteka.go/plugins/golem15/feedback/updates/registry.go
- ../fonoteka.go/plugins/golem15/feedback/updates/01_feedback.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/me_hidden_controller.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/api/winter_errors.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/admin_registry.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/submissions_admin_controller.go
- ../fonoteka.go/plugins/golem15/feedback/controllers/submissions/config_list.yaml
- ../fonoteka.go/plugins/golem15/feedback/classes/image_guard.go
- ../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go
- ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go
- ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go
- ../fonoteka.go/plugins/golem15/feedback/feedback_test.go
- ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go
- ../fonoteka.go/plugins/golem15/feedback
- ../fonoteka.go/.gitmodules
- ../fonoteka.go/go.work
- ../fonoteka.go/go.work.sum
- ../fonoteka.go/go.mod
- ../fonoteka.go/go.sum
- ../fonoteka.go/summer.yaml
- ../fonoteka.go/plugins.gen.go
- ../fonoteka.go/main.go
- ../fonoteka.go/app/app.go
- ../fonoteka.go/parity/check_corpus.go
- ../fonoteka.go/parity/check_corpus_test.go
- ../fonoteka.go/parity/routes.snapshot
- ../fonoteka.go/parity/parity_test.go
- ../fonoteka.go/parity/parity_contract_test.go
- ../fonoteka.go/parity/schema_diff_test.go
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/feedback_seed_test.go
- ../fonoteka.go/parity/fonoteka_reset.php
- ../fonoteka.go/parity/php_parity.sh
- ../fonoteka.go/parity/fixtures/routes/
- ../fonoteka.go/parity/fixtures/jobs/
- ../fonoteka.go/parity/upstream/scripts/
- ../fonoteka.go/parity/README.md
autonomous: true
requirements: [API-08]
estimate:
tokens: 380000
raw_tokens: 380000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-12, the Go port of Golem15.Feedback lives in git@git.golem15.com:golem15/sm-feedback-plugin.git with module `git.golem15.com/golem15/sm-feedback-plugin`, package `feedback`, plugin ID `golem15.feedback` requiring golem15.user, mounted at `plugins/golem15/feedback` as a submodule; tables `golem15_feedback_submissions` and `golem15_feedback_user_preferences` keep PHP's columns; its master is pushed before the pointer is committed."
- "Per D-13, `GET /_feedback/api/v1/{key}/config` (bucket feedback-config, 60 per minute per IP) answers 404 `{\"error\":true,\"message\":\"Not found\"}` unless the widget is enabled and the key matches in constant time, applies the Origin gate (no Origin allowed; empty allow-list fails closed with 403 `{\"error\":true,\"message\":\"Origin not allowed\"}`; host compared lowercase against the allow-list lines), and answers `{\"success\":true,\"data\":{position, allowHide, colors{primary, accent}, labels{title, placeholder, success}}}` with `?lang=en` selecting the English labels."
- "Per D-13, `POST /_feedback/api/v1/{key}/submit` (bucket feedback-submit, 10 per minute per IP) validates as PHP (message, type in bug/feature/other, email, page_url, user_agent, console_log, screenshot up to 10240 KB of jpg/jpeg/png/gif/webp) with 422 `{\"error\":true,\"message\":\"Validation failed\",\"details\":…}`, rejects a non-image screenshot through the copied ImageContentGuard, stores the submission and its public screenshot attachment, dispatches the G15Office sync job and answers 202 `{\"success\":true}`."
- "Per D-13, JWT `PUT /_feedback/api/v1/me/hidden` validates `hidden` as a boolean (422 `{\"error\":\"Validation failed\",\"errors\":…}`) and answers `{\"hidden\":bool}`; the `golem15.user` GetApiArray listener adds `feedback_widget_hidden` (false without a row) to every user payload; an `OPTIONS` preflight on the feedback paths answers 204 from surf's path-scoped CORS layer (config/http.yaml already lists `_feedback/api/*`)."
- "Per D-13 and D-18, the settings are the typed singleton table `golem15_feedback_settings` (enabled, widget_key, allow_hide, position, allowed_origins, color_primary, color_accent, label_title_pl/en, label_placeholder_pl/en, label_success_pl/en, g15office_task_status, g15office_task_priority) with PHP's defaults (widget_key `wk_` plus 32 random characters generated on first creation), an admin settings screen using only cabana field types, a read-only submissions admin list, and `feedback:import-settings` importing the PHP `system_settings` row `golem15_feedback_settings` once."
- "Per D-13, the `SyncFeedbackToG15Office` port is a River job (3 attempts) that POSTs the task JSON to `<base>/_support/api/v1/projects/<project>/tasks` and the screenshot multipart to `<base>/_support/api/v1/tasks/<hashId>/attachments` with a Bearer token through a guarded fetchguard client limited to the configured host; success marks the submission sent with the task id, failure marks it failed with the error and returns the error so River retries; an unconfigured client sends nothing; the requests match PHP's recorded job sidecar (D-15)."
- "`embed.js` is served byte-identical to PHP's file at `GET /plugins/golem15/feedback/assets/js/embed.js`, the path the Nuxt proxy requests."
- "The four feedback routes are recorded against PHP as a new manifest section (`feedbackRouteIDs`, routes.snapshot) and pass the parity diff; the corpus totals become 175 routes, 172 ported, 3 pending."
artifacts:
- path: "../fonoteka.go/plugins/golem15/feedback/plugin.go"
provides: "feedback.Plugin (golem15.feedback)"
contains: "golem15.feedback"
- path: "../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go"
provides: "Config, Submit"
contains: "Origin not allowed"
- path: "../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go"
provides: "G15OfficeClient, CreateTask, AttachFile"
contains: "_support/api/v1"
- path: "../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js"
provides: "the widget script, byte-identical to PHP"
- path: "../fonoteka.go/parity/check_corpus.go"
provides: "feedbackRouteIDs"
contains: "feedbackRouteIDs"
key_links:
- from: "../fonoteka.go/plugins/golem15/feedback/plugin.go"
to: "../fonoteka.go/plugins/golem15/user/classes"
via: "Boot listens for *userclasses.GetApiArrayEvent with listener id golem15.feedback"
pattern: "GetApiArrayEvent"
- from: "../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go"
to: "../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go"
via: "submit dispatches SyncG15OfficeArgs{SubmissionID} through conga"
pattern: "SyncG15OfficeArgs"
- from: "../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go"
to: "summercms.go modules/fetchguard/client.go"
via: "PostJSON and PostMultipart in AllowHostsMode for the configured base_url host"
pattern: "PostMultipart"
prohibitions:
- requirement_id: API-08
category: privacy
statement: "Feedback content (message, email, console log, page URL, screenshot) MUST NOT be sent anywhere except the configured G15Office project, and nothing is sent when G15Office is not configured"
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 visitor or collector sends feedback with a screenshot from the embedded widget, hides the widget if they want, and the team receives the report as a G15Office task; operators configure the widget in the admin (API-08 as reworded by D-13; ROADMAP SC6).
<objective>
Create the shared core plugin sm-feedback-plugin with the full PHP surface (config, submit, preflight, me/hidden, getApiArray hook, embed.js, settings singleton with admin screen and importer, submissions admin list, G15Office River job on the guarded client), mount it, and record its routes as a new parity section.
Purpose: the Nuxt app embeds the widget on every page and reads `feedback_widget_hidden` from the user payload. Decisions: D-12, D-13, D-15, D-18.
Output: sm-feedback-plugin repo and submodule, app wiring, parity section; 175 routes, 172 ported.
Repos: sm-feedback-plugin (new; commit and push master first), then fonoteka.go (pointer bump as its own commit, then wiring and parity). Never stage submodule files from the app repo. 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-PATTERNS.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-04-SUMMARY.md
@.planning/notes/core-plugins-own-repos.md
<interfaces>
- From 14-01: `fetchguard.NewClient`, `PostJSON`, `PostMultipart`, `FormField`, `FormFile`, `Bearer`, `WithTransport`; `tide.LoadUpstream`, `NewUpstreamFake`.
- From 14-04: the submodule bootstrap sequence and the app wiring points (go.work, go.mod require plus replace, summer.yaml, app/app.go PluginIDs and blank import, test activation lists, `summer build` regeneration).
- User plugin: `userclasses.GetApiArrayEvent{User}` with `Collected() map[string]any` (sm-user-plugin controllers/api_controller.go:795-806 already defaults `feedback_widget_hidden` to false), `surf.BucketProvider` and `Buckets() map[string]surf.Bucket` with `surf.TrustedProxies(cfg)` in plugins/golem15/user/plugin.go, routes.go group pattern, `controllers/winter_error_page.html` precedent for per-plugin Winter error helpers.
- fonoteka precedents: models/settings.go (typed singleton), admin_settings.go (`pact.SettingsItem`), controllers/genres_admin_controller.go, classes/image_guard.go (`IsAllowedImage`, `SniffImageMIME`), jobs.go (`conga.Job`, `OnQueue`, `MaxAttempts`), lagoon/attach `Relation{Name, Many, Public}`.
- Parity: check_corpus.go `userAPIRouteIDs`, `realtimeRouteIDs`, `comparePHPSnapshot` (lines 619-630), `expectedRouteCount = 171`; parity_test.go `expectedPHPRoutes = 171`, `expectedPortedRoutes`; routes.snapshot.
- PHP: /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/{Plugin.php, routes.php, controllers/api/FeedbackApiController.php, controllers/api/MeHiddenController.php, jobs/SyncFeedbackToG15Office.php, classes/G15OfficeClient.php, classes/ImageContentGuard.php, models/FeedbackSubmission.php, models/Settings.php, models/UserPreference.php, models/settings/fields.yaml, updates/*.php, lang/{en,pl}/lang.php, config/feedback.php, assets/js/embed.js, tests/}; Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 254, 369) and app/stores/auth.ts (me/hidden).
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- Repo `git@git.golem15.com:golem15/sm-feedback-plugin.git` (master), module `git.golem15.com/golem15/sm-feedback-plugin`, submodule `plugins/golem15/feedback`.
- Package `feedback`: `Plugin` (ID `golem15.feedback`, Requires golem15.user), `Routes`, `Buckets` (`feedback-config`, `feedback-submit`), `Jobs`, `Settings`, `AdminControllers`, `Permissions` (`golem15.feedback.manage_settings`), `Commands`.
- models: `Submission` (`golem15_feedback_submissions`), `UserPreference` (`golem15_feedback_user_preferences`), `Settings` (`golem15_feedback_settings`), `IsWidgetHidden`, `SetWidgetHidden`.
- classes: `IsAllowedImage`, `SniffImageMIME` (copy), `G15OfficeClient`, `NewG15OfficeClient`, `CreateTask`, `AttachFile`, `SyncG15OfficeArgs{SubmissionID}` (kind `golem15.feedback.sync_g15office`, queue `feedback`), `SyncG15Office`.
- controllers/api: `Config`, `Submit`, `MeHidden`; controllers: `golem15.feedback.submissions` admin list.
- Console: `feedback:import-settings`. Config keys `golem15.feedback.g15_office.base_url`, `.token`, `.project`.
- Route `GET /plugins/golem15/feedback/assets/js/embed.js`.
- Parity: `feedbackRouteIDs`, routes.snapshot lines, manifest feedback section, fixtures, `fixtures/jobs/feedback-g15office.upstream.yaml`.
- Tests: `TestFeedbackConfig`, `TestFeedbackSubmit`, `TestFeedbackOptionsPreflight`, `TestMeHidden`, `TestFeedbackApiArrayHook`, `TestSyncG15Office`, `TestFeedbackImportSettings`, `TestEmbedJSServed`, `TestFeedbackPluginBoot`.
## Assumptions
- EDGE-UNCLASSIFIED (API-08, flagged): the edge probe could not classify the feedback requirement; this plan assumes PHP's validation limits (message 5000, page_url 2000, user_agent 500, console_log 20000, screenshot 10240 KB) are the only boundaries, each pinned one step either side in 14-06. Not auto-resolved.
- A3: Winter persists `initSettingsData` defaults on first access, so the Go singleton generates widget_key once when its row is first created.
- A6: River's retry backoff replaces PHP's fixed 30 s; not parity-visible.
- The OPTIONS route is served by surf's CORS layer because pact.Router has no OPTIONS method and adding one would break other Router implementations; the recorded preflight cases must replay green against it.
- PHP has no submissions admin screen; the Go list is net-new (D-13) and not parity-checked.
<tasks>
<task type="tracer">
<name>Task 1: The embedded widget loads its configuration from the new sm-feedback-plugin exactly as from PHP</name>
<reversibility rating="costly">Pushing sm-feedback-plugin master fixes the module path, plugin ID and table names other applications mount (D-12, user-decided).</reversibility>
<precondition>`git ls-remote git@git.golem15.com:golem15/sm-feedback-plugin.git` exits 0, and the isolated PHP instance resets and serves as in 14-02.</precondition>
<files>../fonoteka.go/plugins/golem15/feedback/go.mod, ../fonoteka.go/plugins/golem15/feedback/go.sum, ../fonoteka.go/plugins/golem15/feedback/plugin.go, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/README.md, ../fonoteka.go/plugins/golem15/feedback/config/config.yaml, ../fonoteka.go/plugins/golem15/feedback/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/feedback/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/feedback/models/settings.go, ../fonoteka.go/plugins/golem15/feedback/models/submission.go, ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go, ../fonoteka.go/plugins/golem15/feedback/models/registry.go, ../fonoteka.go/plugins/golem15/feedback/updates/registry.go, ../fonoteka.go/plugins/golem15/feedback/updates/01_feedback.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/winter_errors.go, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/.gitmodules, ../fonoteka.go/go.work, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/summer.yaml, ../fonoteka.go/plugins.gen.go, ../fonoteka.go/main.go, ../fonoteka.go/app/app.go, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/check_corpus_test.go, ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/parity_contract_test.go, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/feedback_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/FeedbackApiController.php (config, keyMatches, origin gate), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/Settings.php (initSettingsData, getAllowedOriginHosts), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/create_feedback_submissions_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/add_type_and_locale_labels.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/updates/create_user_preferences_table.php, ../fonoteka.go/plugins/golem15/user/plugin.go (Buckets, TrustedProxies), ../fonoteka.go/plugins/golem15/user/routes.go, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/models/settings.go, ../fonoteka.go/plugins/golem15/golem/go.mod (14-04 bootstrap result), ../fonoteka.go/parity/check_corpus.go (userAPIRouteIDs, realtimeRouteIDs, comparePHPSnapshot), ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/parity_test.go (expectedPHPRoutes), ../fonoteka.go/parity/user_api_seed_test.go (non-fonoteka seed precedent)</read_first>
<action>Per D-12, D-13, D-18 (thinnest path: widget → config route → settings table).
(1) Repo bootstrap exactly as 14-04 Task 1: clone the empty remote into ../fonoteka.go/plugins/golem15/feedback; go.mod `module git.golem15.com/golem15/sm-feedback-plugin`, go 1.27.0, require summercms and sm-user-plugin with `replace … => ../../../../summercms.go` and `replace git.golem15.com/golem15/sm-user-plugin => ../user`; plugin.go (package `feedback`, ID `golem15.feedback`, Requires `golem15.user`, ConfigFS, LangFS, Migrations, Models, interface assertions, init registration); README with the standard structure and no consuming-application name; commit and push master; `git submodule add` in fonoteka.go and commit `.gitmodules` plus the gitlink alone.
(2) Tables in updates/01_feedback.go (IDs `202610030201_create_feedback_submissions`, `202610030202_create_feedback_user_preferences`, `202610030203_create_feedback_settings`): `golem15_feedback_submissions` and `golem15_feedback_user_preferences` with PHP's columns and indexes, and `golem15_feedback_settings` (singleton id 1, the settings columns in the must-haves); models `Settings` with `Instance(ctx, db)` creating the row with PHP's defaults (enabled true, allow_hide false, widget_key `wk_` plus 32 random alphanumerics from crypto/rand, position bottom-right, g15office_task_priority normal, the pl/en label defaults) and `AllowedOriginHosts()` (split on line breaks, trim, URL host or the raw line, lowercase, unique).
(3) Routes and config handler: `Buckets()` registers `feedback-config` (60 per minute by trusted-proxy client IP) and `feedback-submit` (10 per minute); routes.go mounts group `/_feedback/api/v1` and `GET /{key}/config` with `throttle:feedback-config`. controllers/api `Config(app)`: keyMatches (enabled, non-empty key, `subtle.ConstantTimeCompare`) else 404 `{"error":true,"message":"Not found"}`; Origin gate per the must-haves; 200 body in PHP key order with `?lang=en` selecting the `_en` labels. winter_errors.go holds the plugin's own JSON error writer (plugins in their own repos cannot import fonoteka's api package).
(4) App wiring as 14-04: go.work, go.mod require plus replace, summer.yaml (golem15.feedback before golem15.fonoteka), app/app.go, every activation list, plugins.gen.go and main.go regenerated by `summer build`. schema_diff_test: the submissions and preferences tables match the PHP snapshot when it carries them; `golem15_feedback_settings` joins the Go-only allow-list with the D-18 reason.
(5) Parity: check_corpus.go `feedbackRouteIDs` with `GET /_feedback/api/v1/{key}/config feedback`, `POST /_feedback/api/v1/{key}/submit feedback`, `OPTIONS /_feedback/api/v1/{any} feedback`, `PUT /_feedback/api/v1/me/hidden jwt`, appended in comparePHPSnapshot; routes.snapshot gains the four lines; expectedRouteCount and expectedPHPRoutes become 175; manifest gains the four entries (config ported now, the other three pending until Task 2). Seed the settings row on both sides (fonoteka_reset.php writes `system_settings` item `golem15_feedback_settings`; feedback_seed_test.go writes the Go row) with a fixed widget key `{{secret:widget-key}}` and allowed origins. Record config 200 (pl and `?lang=en`), 404 bad key, 403 foreign Origin, 403 empty allow-list. expectedPortedRoutes 169. feedback_test.go `TestFeedbackConfig` (constant-time compare used, no Origin allowed, empty list fails closed, case-insensitive host) and `TestFeedbackPluginBoot`.</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestFeedbackConfig|TestFeedbackPluginBoot)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestParsePHPRoutesCountAndGroups|TestSchemaMatchesPHPSnapshot)$' &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 &amp;&amp; git -C ../fonoteka.go/plugins/golem15/feedback status --porcelain --branch</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 TestFeedbackConfig, TestFeedbackPluginBoot and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a missing feedback id, a secret or a mismatch; the submodule status shows "ahead" or a modified file.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'path = plugins/golem15/feedback' ../fonoteka.go/.gitmodules` prints 1 and `git -C ../fonoteka.go/plugins/golem15/feedback rev-parse origin/master` exits 0.
- `grep -c 'feedbackRouteIDs' ../fonoteka.go/parity/check_corpus.go` prints at least 2 and `grep -c '_feedback/api/v1' ../fonoteka.go/parity/routes.snapshot` prints 4.
- `grep -n 'const expectedPHPRoutes' ../fonoteka.go/parity/parity_test.go` shows 175 and `grep -n 'const expectedRouteCount' ../fonoteka.go/parity/check_corpus.go` shows 175.
- `grep -c 'ConstantTimeCompare' ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go` prints at least 1.
</acceptance_criteria>
<done>The new core plugin is pushed and mounted, and the widget's config call answers exactly as PHP, including the fail-closed Origin gate.</done>
</task>
<task type="auto">
<name>Task 2: A visitor submits feedback with a screenshot, a collector hides the widget, and the user payload reports it</name>
<files>../fonoteka.go/plugins/golem15/feedback/plugin.go, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/feedback_api_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/api/me_hidden_controller.go, ../fonoteka.go/plugins/golem15/feedback/classes/image_guard.go, ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go, ../fonoteka.go/plugins/golem15/feedback/models/submission.go, ../fonoteka.go/plugins/golem15/feedback/models/user_preference.go, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/feedback_seed_test.go, ../fonoteka.go/parity/fixtures/routes/</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/FeedbackApiController.php (submit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/controllers/api/MeHiddenController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/classes/ImageContentGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/FeedbackSubmission.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/UserPreference.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/Plugin.php (getApiArray listener), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/tests/ (functional cases), ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go (multipart intake and attach), ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (GetApiArrayEvent listener lines 92-103), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (lines 795-806), summercms.go modules/surf/cors.go (OPTIONS handling), ../fonoteka.go/config/http.yaml</read_first>
<action>Per D-13. Changes in the plugin checkout are committed and pushed there; the pointer bump in fonoteka.go is its own commit.
(1) Submit: classes/image_guard.go copies fonoteka's `IsAllowedImage` and `SniffImageMIME` (per-plugin copies are deliberate, as in PHP); models.Submission attaches `screenshot` through lagoon/attach `Relation{Name: "screenshot", Public: true}`; `Submit(app)` for `POST /{key}/submit` (`throttle:feedback-submit`): key check and Origin gate as config, multipart parse, PHP's validation rules and 422 envelope, ImageContentGuard failure → 422 with `details.screenshot = ["The file is not a valid image."]`, create the submission (status pending) and attach the screenshot in one lagoon.Transaction that also dispatches `SyncG15OfficeArgs{SubmissionID}` (kind `golem15.feedback.sync_g15office`, queue `feedback`, label as PHP's job name), answer 202 `{"success":true}`. `SyncG15OfficeArgs` and its kind and queue are declared in classes/sync_g15office.go here; the queue is not served until Task 3 registers the worker, and conga inserts an unregistered kind through its insert-only client, so the job waits queued exactly as a PHP job waits for its worker.
(2) me/hidden: a second group `/_feedback/api/v1` with `jwt.auth` mounts `PUT /me/hidden`; `MeHidden(app)` validates `hidden` required boolean (Laravel boolean semantics) → 422 `{"error":"Validation failed","errors":…}`, upserts the preference for the JWT principal only and answers `{"hidden":bool}`. Boot registers the GetApiArray listener (`"golem15.feedback"`) setting `feedback_widget_hidden` from `models.IsWidgetHidden(ctx, db, userID)`.
(3) Preflight: no OPTIONS route is registered; surf's path-scoped CORS middleware answers OPTIONS on `_feedback/api/*` with 204. Record `OPTIONS /_feedback/api/v1/{any}` with and without preflight headers and confirm both replay green; `TestFeedbackOptionsPreflight` asserts 204 and an empty body through the assembled router.
(4) Parity: record submit 202 (with and without screenshot), 422 validation, 422 bad image, 403 origin; me/hidden 200 true and false and 422; flip submit, OPTIONS and me/hidden; expectedPortedRoutes 172 (175 routes, 3 pending). Tests: `TestFeedbackSubmit` (validation one step either side of each limit, screenshot attached publicly, job dispatched in the same transaction, a rolled-back insert dispatches nothing), `TestMeHidden` (only the caller's row changes), `TestFeedbackApiArrayHook` (false without a row, true after PUT, through the user plugin's fetch payload), classes_test.go `TestImageGuardCopy` (same verdicts as fonoteka's guard on the shared vectors).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestFeedbackSubmit|TestMeHidden|TestFeedbackApiArrayHook|TestFeedbackOptionsPreflight|TestImageGuardCopy|TestFeedbackConfig)$' &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus)$' &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 TestFeedbackSubmit, TestMeHidden, TestFeedbackApiArrayHook, TestFeedbackOptionsPreflight and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or mismatch.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows 172.
- `grep -c 'feedback_widget_hidden' ../fonoteka.go/plugins/golem15/feedback/plugin.go` prints at least 1.
- `grep -c 'Options(' ../fonoteka.go/plugins/golem15/feedback/routes.go` prints 0.
- The four feedback manifest entries have `status: ported`.
</acceptance_criteria>
<done>Every feedback route answers as PHP, and the user payload carries the hide preference.</done>
</task>
<task type="auto">
<name>Task 3: The team receives each submission as a G15Office task with its screenshot, and operators manage the widget and read submissions in the admin</name>
<files>../fonoteka.go/plugins/golem15/feedback/classes/g15office_client.go, ../fonoteka.go/plugins/golem15/feedback/classes/sync_g15office.go, ../fonoteka.go/plugins/golem15/feedback/classes/classes_test.go, ../fonoteka.go/plugins/golem15/feedback/jobs.go, ../fonoteka.go/plugins/golem15/feedback/admin.go, ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission/columns.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission/fields.yaml, ../fonoteka.go/plugins/golem15/feedback/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/feedback/controllers/submissions_admin_controller.go, ../fonoteka.go/plugins/golem15/feedback/controllers/submissions/config_list.yaml, ../fonoteka.go/plugins/golem15/feedback/console/import_settings.go, ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js, ../fonoteka.go/plugins/golem15/feedback/routes.go, ../fonoteka.go/plugins/golem15/feedback/config/config.yaml, ../fonoteka.go/plugins/golem15/feedback/README.md, ../fonoteka.go/plugins/golem15/feedback/feedback_test.go, ../fonoteka.go/plugins/golem15/feedback, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/fixtures/jobs/, ../fonoteka.go/parity/upstream/scripts/, ../fonoteka.go/parity/README.md</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/jobs/SyncFeedbackToG15Office.php (whole file, description format lines 198-226 per research), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/classes/G15OfficeClient.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/config/feedback.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/models/settings/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/Plugin.php (permissions, settings), /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/assets/js/embed.js, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/nuxt.config.ts (lines 254, 369), ../fonoteka.go/plugins/golem15/fonoteka/admin_settings.go, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genres_admin_controller.go, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go (14-04 importer precedent), summercms.go modules/cabana/form_schema.go (accepted field types and keys), ../fonoteka.go/parity/php_parity.sh</read_first>
<action>Per D-13, D-15, D-18. Plugin changes committed and pushed in the checkout first; pointer bump in fonoteka.go as its own commit.
(1) classes/g15office_client.go `G15OfficeClient` from config `golem15.feedback.g15_office.{base_url, token, project}` (defaults empty; README names the SUMMER_ overrides), `IsConfigured()`, `CreateTask(ctx, fields *orderedFields) (map[string]any, error)` → PostJSON `<base>/_support/api/v1/projects/<project>/tasks` with `Authorization: Bearer`, `Accept: application/json`, and `AttachFile(ctx, hashID, name, mime string, body io.Reader)` → PostMultipart field `file` to `<base>/_support/api/v1/tasks/<hashID>/attachments`; both through `fetchguard.NewClient` in AllowHostsMode limited to the base_url host, 30 s timeout; decode PHP's way (connection error, invalid response body, `error` truthy → its message); an unconfigured client fails with PHP's not-configured message before any request. classes/sync_g15office.go `SyncG15Office(ctx, deps, args)`: missing submission → nil; title `Feedback: ` plus the collapsed message limited to 80 characters as Str::limit; PHP's markdown description; type map bug→Bug, feature→Feature, other→Task; fields filtered of null and empty with priority default normal and the settings task status; create then attach the screenshot when present; success → status sent plus g15office_task_id; failure → status failed plus g15office_error, then return the error so River retries.
(2) Record the job sidecar: with `G15_OFFICE_BASE_URL=https://office.parity.test`, a parity token and project exported by php_parity.sh, the proxy in script mode answering both G15Office calls, a sync-queue submit makes PHP run the job inline; save it as `parity/fixtures/jobs/feedback-g15office.upstream.yaml`. `TestSyncG15Office` (classes/classes_test.go) replays it through the fake with WithTransport (request bodies, Bearer placeholder, multipart part names and sha256 asserted), and covers unconfigured (no request, failed status, error returned), error envelope and missing task id.
(3) Admin and assets: `Settings()` item (code `settings`, model `Golem15\Feedback\Models\Settings`, permission `golem15.feedback.manage_settings`, form models/settings/fields.yaml using only cabana field types: colors as text fields, widget_key read-only through attributes); `golem15.feedback.submissions` read-only list controller (columns id, type, status, email, page_url, created_at; message shown as text, never HTML); `Permissions()`; `feedback:import-settings` imports `system_settings` item `golem15_feedback_settings` into the singleton when the row still holds its generated defaults (updated_at equals created_at) and otherwise refuses unless `--force`. Copy embed.js byte for byte and serve it with go:embed at `GET /plugins/golem15/feedback/assets/js/embed.js` (`Content-Type: application/javascript; charset=utf-8`). README completes Usage, configuration keys, CLI command and Testing.
(4) Tests in feedback_test.go: `TestEmbedJSServed` (bytes equal the PHP file's sha256, content type), `TestFeedbackImportSettings` (imports once, missing system_settings is a clean message), `TestFeedbackAdminSchemas` (the settings form and submissions list compile under cabana at boot).</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 -race -v -run '^(TestSyncG15Office|TestEmbedJSServed|TestFeedbackImportSettings|TestFeedbackAdminSchemas|TestFeedbackSubmit)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/feedback/... -count=1 &amp;&amp; go -C ../fonoteka.go test ./... -count=1 -short &amp;&amp; git -C ../fonoteka.go/plugins/golem15/feedback status --porcelain --branch</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 TestSyncG15Office, TestEmbedJSServed, TestFeedbackImportSettings and TestFeedbackAdminSchemas; the short suite reports FAIL; the submodule status shows "ahead" or a modified file.</fails_when>
</verify>
<acceptance_criteria>
- `sha256sum /media/nvme/dev/golem15/fonoteka/plugins/golem15/feedback/assets/js/embed.js ../fonoteka.go/plugins/golem15/feedback/assets/js/embed.js | awk '{print $1}' | uniq | wc -l` prints 1.
- `grep -c 'colorpicker' ../fonoteka.go/plugins/golem15/feedback/models/settings/fields.yaml` prints 0.
- `ls ../fonoteka.go/parity/fixtures/jobs/feedback-g15office.upstream.yaml` succeeds and it contains a `{{` placeholder in its Authorization header.
- `grep -c 'conga.MaxAttempts(3)' ../fonoteka.go/plugins/golem15/feedback/jobs.go` prints 1.
</acceptance_criteria>
<done>Submissions reach G15Office exactly as PHP sent them, operators manage the widget and read submissions, and the widget script is served where Nuxt expects it.</done>
</task>
</tasks>
## Canon referrals (not minted as prohibitions)
- Cross-origin abuse of the widget is canon (CORS/CSRF) — the Origin gate truth and /gsd-secure-phase.
- Screenshot type spoofing is canon (OWASP file upload) — the image guard; /gsd-secure-phase.
- Feedback personal data retention is canon (GDPR) — /gsd-secure-phase; this port keeps PHP's retention unchanged.
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Any website → feedback routes | Public, unauthenticated widget calls from embedding origins |
| Upload → storage | Screenshots are stored and publicly attached |
| Job → G15Office | Submission content and a bearer token leave the system |
| JWT user → preference row | Per-user state |
| Admin → submissions list | User-supplied text rendered in the admin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-30 | Spoofing | config and submit | high | mitigate | Constant-time widget key compare; Origin allow-list failing closed when empty; TestFeedbackConfig (Task 1), TestFeedbackSubmit (Task 2). |
| T-14-31 | Denial of Service | public feedback routes | medium | mitigate | Named buckets feedback-config 60/min and feedback-submit 10/min by trusted-proxy client IP; route throttles pinned in TestFeedbackSubmit (Task 2). |
| T-14-32 | Tampering | screenshot | medium | mitigate | Rule mimes and 10240 KB, ImageContentGuard sniff; TestFeedbackSubmit bad-image case, TestImageGuardCopy (Task 2). |
| T-14-33 | Information Disclosure | G15Office token | high | mitigate | Token from config only, Bearer masked in the sidecar, never logged; TestSyncG15Office (Task 3). |
| T-14-34 | Information Disclosure | submission forwarding | medium | mitigate | Guarded client limited to the configured host; unconfigured sends nothing; TestSyncG15Office (Task 3). |
| T-14-35 | Elevation of Privilege | me/hidden | medium | mitigate | Preference keyed by the JWT principal only; TestMeHidden (Task 2). |
| T-14-36 | Tampering | admin submissions list | low | mitigate | Columns rendered as text by the admin SPA, no HTML field types; TestFeedbackAdminSchemas (Task 3). |
| T-14-SC | Tampering | package installs | low | accept | No new third-party module; only the in-house sm-feedback-plugin module. |
</threat_model>
<verification>
- sm-feedback-plugin: master pushed; plugin tests green inside the application workspace.
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; corpus 175 routes, 172 ported, 0 failing, 3 pending; check_corpus `--require-recorded --check-secrets` green.
</verification>
<success_criteria>
- sm-feedback-plugin ports the PHP plugin in full and is mounted in the application.
- Feedback routes pass the parity diff; the G15Office job matches PHP's recorded requests offline.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-05-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,266 @@
---
phase: 14-domain-jobs-and-external-integrations
plan: 06
type: execute
wave: 6
depends_on: ["14-05"]
files_modified:
- scripts/check-phase14.sh
- modules/fetchguard/client_coverage_test.go
- modules/tide/upstream_coverage_test.go
- modules/sunscreen/sunscreen_coverage_test.go
- modules/beachcomber/typesense/engine_test.go
- ../fonoteka.go/parity/discogs_truth_tables.php
- ../fonoteka.go/parity/README.md
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/php_truth_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/coverage_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/phase14_security_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/phase14_edges_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/phase14_jobs_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/
- ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase14_controllers_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/phase14_classes_test.go
- ../fonoteka.go/plugins/golem15/golem/classes/services/coverage_test.go
- ../fonoteka.go/plugins/golem15/golem/classes/providers/coverage_test.go
- ../fonoteka.go/plugins/golem15/golem/golem_admin_test.go
- ../fonoteka.go/plugins/golem15/golem
- ../fonoteka.go/plugins/golem15/feedback/feedback_edges_test.go
- ../fonoteka.go/plugins/golem15/feedback/classes/coverage_test.go
- ../fonoteka.go/plugins/golem15/feedback
- .planning/phases/14-domain-jobs-and-external-integrations/14-SECURITY-REVIEW.md
- .planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md
- .planning/phases/14-domain-jobs-and-external-integrations/COVERAGE.md
- .planning/REQUIREMENTS.md
- .planning/todos/pending/fetchguard-guarded-http-client.md
- .planning/todos/pending/redacting-slog-handler.md
- .planning/todos/done/fetchguard-guarded-http-client.md
- .planning/todos/done/redacting-slog-handler.md
autonomous: true
requirements: [JOBS-02, JOBS-03, SRCH-02, INTG-01, INTG-02, API-08, CLI-05]
estimate:
tokens: 380000
raw_tokens: 380000
tasks: 4
confidence: low
must_haves:
truths:
- "`scripts/check-phase14.sh` (summercms.go) is fail-closed with `--self-test`, `--go`, `--parity`, `--named`, `--removal`, `--coverage`, `--evidence` and `--all`; `--all` (every stage except `--removal`) runs vet and tests in summercms.go and in fonoteka.go including both new submodule plugins with -race, the parity corpus at exactly 175 recorded, 172 ported and passing, 3 pending (the D-09 routes), every TestFonotekaNuxtFlows subtest, TestUpstreamSidecarsAreReplayed, check_corpus `--require-recorded --check-secrets`, TestDocsTree and docs:build --check; `--self-test` proves each detector fails on planted input."
- "PHP truth tables generated by `parity/discogs_truth_tables.php` (running the PHP DiscogsMapper, DiscogsInputParser, ReleaseMatchScorer and PriceSuggestionResolver directly, as csv_truth_tables.php does) are committed under classes/discogs/testdata/php_*.json and `TestPHPTruthDiscogs` reproduces every row byte for byte, including price rounding."
- "Every Go package created or changed in Phase 14 reaches at least 80% statement coverage: summercms.go fetchguard, tide, sunscreen, beachcomber and beachcomber/typesense; fonoteka classes/discogs and console; every Phase 14 function in the fonoteka root, classes and controllers/api packages at 80% by `go tool cover -func`; every package of sm-golem-plugin and sm-feedback-plugin; the numbers are recorded in 14-VALIDATION.md."
- "`TestRouteTablePhase14` pins all eleven Phase 14 fonoteka routes and the four feedback routes (group, method, constraints, scope, throttles and buckets), and `FuzzWriteEndpoints` covers the Phase 14 write routes (apply-release, import/discogs, recognize, cover-price, credential tests, feedback submit and me/hidden) asserting no column outside each allow-list changes and no 500 PHP does not answer."
- "Each mitigated T-14 threat of plans 14-01 to 14-05 has a named test in `TestPhase14Threats` (or the named module test) that fails when its protection is removed; `check-phase14.sh --removal` applies anchor-exact mutations, requires the named test to fail on an assertion and restores each file byte for byte (cmp); 14-SECURITY-REVIEW.md maps every T-14 id to category, severity, disposition, protecting file and that test."
- "Every edge truth of plans 14-02 to 14-05 is pinned one step either side in `TestPhase14Edges` and the feedback edge tests (limiter threshold and budget, Retry-After parsing, MAX_CANDIDATES, prune cutoff, inbound limits 60/20/10/12, feedback validation limits), and each prohibition of plans 14-01 to 14-05 has a negative test the gate requires by name."
- "Per D-06, D-07, D-13 and D-14, `--evidence` refuses a REQUIREMENTS.md that still carries the SDK wording for INTG-02 or sitemap for API-08, and a ROADMAP Phase 14 section without the album Discogs and recognize routes or the sm-golem-plugin and sm-feedback-plugin repos; REQUIREMENTS.md marks JOBS-02, JOBS-03, SRCH-02, INTG-01, INTG-02, API-08 and CLI-05 Complete; 14-VALIDATION.md has real task ids, no pending row, `nyquist_compliant: true` and `wave_0_complete: true`; the two folded todos move to `.planning/todos/done/`."
- "COVERAGE.md lists every Discogs, Anthropic, OpenAI-compatible and G15Office capability the PHP reference uses as INTEGRATE with a named test, and every other capability as OPT-OUT with a reason; `--evidence` refuses an INTEGRATE row without a passing named test."
- "PriceSuggestionResolver results equal PHP's for every truth-table input (every currency, empty suggestions, rounding ties), pinned by TestPHPTruthDiscogs."
artifacts:
- path: "scripts/check-phase14.sh"
provides: "fail-closed Phase 14 gate"
contains: "EXPECTED_PORTED=172"
- path: "../fonoteka.go/parity/discogs_truth_tables.php"
provides: "PHP truth-table generator for the Discogs pure classes"
contains: "PriceSuggestionResolver"
- path: "../fonoteka.go/plugins/golem15/fonoteka/phase14_security_test.go"
provides: "TestPhase14Threats"
contains: "TestPhase14Threats"
- path: ".planning/phases/14-domain-jobs-and-external-integrations/14-SECURITY-REVIEW.md"
provides: "threat-to-test evidence"
key_links:
- from: "scripts/check-phase14.sh"
to: ".planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md"
via: "--named reads every test the validation map names; --evidence refuses pending or TBD rows"
pattern: "14-VALIDATION.md"
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/php_truth_test.go"
to: "../fonoteka.go/parity/discogs_truth_tables.php"
via: "testdata/php_*.json written by the PHP generator"
pattern: "php_"
prohibitions:
- requirement_id: INTG-01
category: transparency
statement: "Pending routes MUST NOT be counted as passing; the three D-09 routes stay pending and the gate fails if any pending count differs from 3"
status: resolved
verification: test
- requirement_id: INTG-02
category: transparency
statement: "A threat MUST NOT be marked mitigated in 14-SECURITY-REVIEW.md without a named test that was run and seen to fail when its protection is removed"
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: the phase is proven. Every Phase 14 package has full unit coverage, the PHP truth tables pin the Discogs rules, each threat has a test that fails without its protection, and one gate script says green or red for the whole phase (lean-mode rule 3: unit tests are the last plan).
<objective>
Write the PHP truth-table generator and tests, bring every Phase 14 package to at least 80% coverage with edge and threat tests, extend the route-table and fuzz tests, build `scripts/check-phase14.sh`, and record the security review, validation evidence, requirement status and API coverage.
Purpose: CLAUDE.md lean-mode rule 3; the gate is what `/gsd-verify-work 14` runs. Decisions verified: every D-01 to D-21 that produced code, plus the D-06/D-07/D-13/D-14 rewording.
Output: tests in all four repositories, the gate script, 14-SECURITY-REVIEW.md, a validated 14-VALIDATION.md, COVERAGE.md checks, REQUIREMENTS statuses.
Repos: summercms.go (gate, framework tests, planning docs in a separate commit), fonoteka.go, sm-golem-plugin and sm-feedback-plugin (tests committed and pushed in each checkout, then one pointer-bump commit per plugin in fonoteka.go). 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-VALIDATION.md
@.planning/phases/14-domain-jobs-and-external-integrations/COVERAGE.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-01-SUMMARY.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-02-SUMMARY.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-03-SUMMARY.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-04-SUMMARY.md
@.planning/phases/14-domain-jobs-and-external-integrations/14-05-SUMMARY.md
@scripts/check-phase13.sh
<interfaces>
- check-phase13.sh structure: env overrides (`PHASE13_ROOT`, `PHASE13_APP`, `PHASE13_PHASE_DIR`), `APP_PLUGINS`, `EXPECTED_PORTED`, `EXPECTED_PENDING`, `COVERAGE_FLOOR=80`, the python `go test -json` detector (exit 1 fail, 2 skip, 3 zero tests, 4 non-JSON, 5 required test missing, 6 race), `expect_detect`, `run_go`, `corpus_scan`, `manifest_count`, `run_parity`, `run_named`, `coverage_report`, `func_floor`, `removal_table`, `removal_harness` (refuses dirty files, cmp restore), `evidence_check`, `run_self_test`.
- 13-06 precedents: `FuzzWriteEndpoints` with its committed seed corpus under testdata/fuzz, `TestPhase13Threats`, route-table tests over `surf.BuildRouter(...).Routes()`.
- parity/csv_truth_tables.php (PHP_ROOT env, require_once the classes, stub ApplicationException, write_table with JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION).
- Threat ids T-14-01 to T-14-36 and T-14-SC from the five earlier plans' threat models.
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- `scripts/check-phase14.sh` (`--self-test --go --parity --named --removal --coverage --evidence --all`; `PHASE14_ROOT`, `PHASE14_APP`, `PHASE14_PHASE_DIR`; `EXPECTED_ROUTES=175`, `EXPECTED_PORTED=172`, `EXPECTED_PENDING=3`, `COVERAGE_FLOOR=80`).
- `parity/discogs_truth_tables.php`; `classes/discogs/testdata/php_mapper.json`, `php_input_parser.json`, `php_scorer.json`, `php_price_suggestion.json`.
- Tests: `TestPHPTruthDiscogs`, `TestPhase14Threats`, `TestPhase14Edges`, `TestPhase14Jobs`, `TestRouteTablePhase14` (complete), `FuzzWriteEndpoints` (Phase 14 routes), coverage tests per package, `TestFeedbackEdges`, `TestGolemAdminSchemas`.
- Evidence: `14-SECURITY-REVIEW.md`, validated `14-VALIDATION.md`, REQUIREMENTS statuses, todos moved to done.
## Assumptions
- The gate lives in summercms.go/scripts and runs application commands in the sibling `../fonoteka.go`, as check-phase13.sh does; the two plugin submodules are tested through the application workspace.
- Live vendor calls are never part of the gate (D-15); the manual-only rows of 14-VALIDATION.md stay manual.
<tasks>
<task type="tracer">
<name>Task 1: One command proves the whole phase green or red: check-phase14.sh runs both repositories, the corpus and the named tests</name>
<files>scripts/check-phase14.sh</files>
<read_first>scripts/check-phase13.sh (whole file), .planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md, ../fonoteka.go/parity/parity_test.go (expectedPHPRoutes, expectedPortedRoutes), ../fonoteka.go/parity/check_corpus.go</read_first>
<action>Copy check-phase13.sh's structure into scripts/check-phase14.sh with `PHASE14_*` overrides, `PHASE_DIR` pointing at the Phase 14 directory, `APP_PLUGINS=(./plugins/golem15/fonoteka/... ./plugins/golem15/user/... ./plugins/golem15/golem/... ./plugins/golem15/feedback/...)`, `EXPECTED_ROUTES=175`, `EXPECTED_PORTED=172`, `EXPECTED_PENDING=3`, `COVERAGE_FLOOR=80`, and the python detector renamed `phase14_detect` with the same exit codes. Wire `--go` (vet and `go test ./... -count=1` in summercms.go; vet and `go test ./... -count=1 -race` over APP_PLUGINS plus `./app/... ./parity/...` in fonoteka.go), `--parity` (TestParityCorpus with the three counts read from its output and from the manifest, every TestFonotekaNuxtFlows subtest, TestBroadcastGoldens, TestUpstreamSidecarsAreReplayed, check_corpus `--require-recorded --check-secrets`, TestDocsTree, docs:build --check) and `--self-test` (each detector fails on a planted failing, skipped, zero-test, racy and non-JSON input, and a planted pending count of 4 fails `--parity`). Tasks 3 and 4 add the `--named`, `--removal`, `--coverage` and `--evidence` stages to this script; `--all` runs every stage the script defines except `--removal`, which edits tracked source and runs on its own (the Phase 13 precedent). An unknown flag prints usage and exits 2.</action>
<verify>
<automated>bash -n scripts/check-phase14.sh &amp;&amp; bash scripts/check-phase14.sh --self-test &amp;&amp; bash scripts/check-phase14.sh --go &amp;&amp; bash scripts/check-phase14.sh --parity</automated>
<fails_when>Non-zero exit from any stage; the self-test reports a detector that did not fail on its planted input; --parity reports counts other than 175 recorded, 172 ported and passing, 3 pending, or any failing flow, sidecar, secret or docs check.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'EXPECTED_PORTED=172' scripts/check-phase14.sh` and `grep -c 'EXPECTED_PENDING=3' scripts/check-phase14.sh` each print 1.
- `grep -c 'plugins/golem15/golem/\.\.\.' scripts/check-phase14.sh` and `grep -c 'plugins/golem15/feedback/\.\.\.' scripts/check-phase14.sh` each print at least 1.
- `bash scripts/check-phase14.sh --bogus` exits 2 and prints the usage text.
</acceptance_criteria>
<done>The gate runs end to end over both repositories and the corpus with exact counts, and fails closed.</done>
</task>
<task type="auto">
<name>Task 2: The Discogs rules match PHP row for row, and every Phase 14 application package is fully covered at its edges</name>
<files>../fonoteka.go/parity/discogs_truth_tables.php, ../fonoteka.go/parity/README.md, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/php_truth_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/phase14_edges_test.go, ../fonoteka.go/plugins/golem15/fonoteka/phase14_jobs_test.go, ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go, ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/plugins/golem15/fonoteka/console/phase14_commands_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/phase14_controllers_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/phase14_classes_test.go</files>
<read_first>../fonoteka.go/parity/csv_truth_tables.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/{DiscogsMapper,DiscogsInputParser,ReleaseMatchScorer,PriceSuggestionResolver}.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/unit/{DiscogsMapperTest,DiscogsInputParserTest,ReleaseMatchScorerTest,PriceSuggestionResolverTest,DiscogsCandidateMapperTest}.php, ../fonoteka.go/plugins/golem15/fonoteka/write_endpoints_fuzz_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes_table_phase14_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/*.go, the 14-02 to 14-05 SUMMARY files (edge truths and test names)</read_first>
<action>Per lean-mode rule 3 and the edge truths of plans 14-02 to 14-05.
(1) parity/discogs_truth_tables.php follows csv_truth_tables.php: PHP_ROOT from the environment, require_once the four classes, stub what they need, and write php_mapper.json (mapRelease, mapSearchResult, mapMasterVersion over the PHP unit-test fixtures plus edge releases: missing images, multiple formats, unicode artists, master releases), php_input_parser.json (URLs, ids, barcodes with and without check digits, garbage), php_scorer.json and php_price_suggestion.json (every currency, empty suggestions, rounding ties) into classes/discogs/testdata. php_truth_test.go `TestPHPTruthDiscogs` replays every row and compares JSON bytes. README documents the regeneration command.
(2) Coverage to at least 80%: classes/discogs (coverage_test.go for client error branches, limiter store errors, applicator and cover fetcher branches), console (phase14_commands_test.go), and every Phase 14 function in the root (workers, wiring), classes (album_recognition, CSV write-service variants, WR-02 paths) and controllers/api (match, apply, import, cover-price, credential tests, recognize) packages by `go tool cover -func`.
(3) phase14_edges_test.go `TestPhase14Edges`: limiter 50/51 and budget 15/16 s, Retry-After absent/non-numeric/numeric, MAX_CANDIDATES 10/11, prune cutoff at exactly 90 days and one second older, inbound limits 60/61, 20/21, 10/11 and the token cover-price throttle 12/13; phase14_jobs_test.go `TestPhase14Jobs` (cancel during a paused match, resume after re-dispatch writes the same rows, import of an already written row is skipped). routes_table_phase14_test.go completes `TestRouteTablePhase14` for the eleven fonoteka routes. write_endpoints_fuzz_test.go adds the Phase 14 write routes to `FuzzWriteEndpoints` with a committed seed corpus.</action>
<verify>
<automated>go -C ../fonoteka.go vet ./... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes/discogs -count=1 -race -v -run '^(TestPHPTruthDiscogs|TestPhase14Edges|TestPhase14Jobs|TestRouteTablePhase14|FuzzWriteEndpoints)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... -count=1 -cover</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 TestPHPTruthDiscogs, TestPhase14Edges, TestPhase14Jobs, TestRouteTablePhase14 and FuzzWriteEndpoints; the cover run reports below 80.0% for classes/discogs or console.</fails_when>
</verify>
<acceptance_criteria>
- `ls ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/php_mapper.json ../fonoteka.go/plugins/golem15/fonoteka/classes/discogs/testdata/php_price_suggestion.json` succeeds.
- `grep -c 'PriceSuggestionResolver' ../fonoteka.go/parity/discogs_truth_tables.php` prints at least 1.
- `ls ../fonoteka.go/plugins/golem15/fonoteka/testdata/fuzz/FuzzWriteEndpoints/ | wc -l` is larger than before this task.
</acceptance_criteria>
<done>The Discogs rules are pinned to PHP's own output and the application side of the phase is covered at its edges.</done>
</task>
<task type="auto">
<name>Task 3: Each Phase 14 threat has a test that fails without its protection, and the framework and both new plugins are fully covered</name>
<files>modules/fetchguard/client_coverage_test.go, modules/tide/upstream_coverage_test.go, modules/sunscreen/sunscreen_coverage_test.go, modules/beachcomber/typesense/engine_test.go, ../fonoteka.go/plugins/golem15/fonoteka/phase14_security_test.go, ../fonoteka.go/plugins/golem15/golem/classes/services/coverage_test.go, ../fonoteka.go/plugins/golem15/golem/classes/providers/coverage_test.go, ../fonoteka.go/plugins/golem15/golem/golem_admin_test.go, ../fonoteka.go/plugins/golem15/golem, ../fonoteka.go/plugins/golem15/feedback/feedback_edges_test.go, ../fonoteka.go/plugins/golem15/feedback/classes/coverage_test.go, ../fonoteka.go/plugins/golem15/feedback, scripts/check-phase14.sh, .planning/phases/14-domain-jobs-and-external-integrations/14-SECURITY-REVIEW.md</files>
<read_first>scripts/check-phase13.sh (removal_table, removal_harness, run_named, coverage_report, func_floor), .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-SECURITY-REVIEW.md (format), the threat_model blocks of 14-01-PLAN.md to 14-05-PLAN.md, ../fonoteka.go/plugins/golem15/fonoteka/phase13_security_test.go, modules/fetchguard/client.go, modules/tide/upstream.go, modules/tide/upstream_proxy.go, modules/sunscreen/sunscreen.go</read_first>
<action>Per security enforcement (ASVS level 1, block on high).
(1) Coverage at 80% or more for modules/fetchguard, modules/tide, modules/sunscreen, modules/beachcomber and modules/beachcomber/typesense (new *_coverage_test.go files for remaining branches: transport errors, cap edges, proxy forward-mode refusal paths, CA reuse errors, redaction of LogValuer groups), and for every package of sm-golem-plugin (services, providers, security, factories, valueobjects, models, console, controllers; `TestGolemAdminSchemas` compiles the admin YAML under cabana) and sm-feedback-plugin (classes, controllers/api, models, console; `TestFeedbackEdges` pins each validation limit one step either side). Plugin tests are committed and pushed in each checkout, then each pointer is bumped in fonoteka.go in its own commit.
(2) phase14_security_test.go `TestPhase14Threats`: one subtest per mitigated application threat (T-14-08 to T-14-36) that drives the real handler or worker and asserts the protection; framework threats (T-14-01 to T-14-07) map to their named module tests. check-phase14.sh `--named` requires every test named in 14-VALIDATION.md and the security review by exact name; `--removal` gets a `removal_table` row per mitigated threat (anchor-exact mutation of the protecting line, the named test that must fail on an assertion, cmp restore; refuses files with uncommitted changes); `--coverage` enforces the package floors and the per-function floors (`go tool cover -func`) for the root, classes and controllers/api Phase 14 functions.
(3) 14-SECURITY-REVIEW.md maps every T-14 id (including T-14-SC and the accepted T-14-28) to category, severity, disposition, protecting file and the named test seen failing under `--removal`, with the canon referrals of plans 14-02 to 14-05 listed as covered by this review.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test ./modules/fetchguard ./modules/tide ./modules/sunscreen ./modules/beachcomber/... -count=1 -race -cover &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestPhase14Threats)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/golem/... ./plugins/golem15/feedback/... -count=1 -race -cover &amp;&amp; bash scripts/check-phase14.sh --named &amp;&amp; bash scripts/check-phase14.sh --coverage &amp;&amp; bash scripts/check-phase14.sh --removal</automated>
<fails_when>Any command exits non-zero; a cover line below 80.0% for a listed package; the verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestPhase14Threats"; --removal reports a mutation whose named test still passes or a file that does not restore byte for byte.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'T-14-' .planning/phases/14-domain-jobs-and-external-integrations/14-SECURITY-REVIEW.md` prints at least 37.
- `grep -cE '^run_(named|removal|coverage)\(\)' scripts/check-phase14.sh` prints 3.
- `git -C ../fonoteka.go/plugins/golem15/golem status --porcelain --branch` and the same for feedback show no "ahead".
</acceptance_criteria>
<done>Framework, golem and feedback code are fully covered and every threat is proven by a test that fails without its protection.</done>
</task>
<task type="auto">
<name>Task 4: The phase record is complete: validation signed off, requirements marked, API coverage checked, folded todos closed, and the whole gate green</name>
<files>scripts/check-phase14.sh, .planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md, .planning/phases/14-domain-jobs-and-external-integrations/COVERAGE.md, .planning/REQUIREMENTS.md, .planning/todos/pending/fetchguard-guarded-http-client.md, .planning/todos/pending/redacting-slog-handler.md, .planning/todos/done/fetchguard-guarded-http-client.md, .planning/todos/done/redacting-slog-handler.md</files>
<read_first>.planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md, .planning/phases/14-domain-jobs-and-external-integrations/COVERAGE.md, .planning/REQUIREMENTS.md, .planning/ROADMAP.md (Phase 14 section), scripts/check-phase13.sh (evidence_check), the 14-01 to 14-05 SUMMARY files</read_first>
<action>Per D-06, D-07, D-13, D-14 and the API coverage contract.
(1) `--evidence` in check-phase14.sh refuses: REQUIREMENTS.md still containing the SDK wording for INTG-02 or "sitemap output" in API-08; a ROADMAP Phase 14 `**Repos:**` line plus success-criteria block (the lines between `**Success Criteria**` and `**Plans:**`, not the plan list) missing `albums/import/discogs`, `recognize`, `sm-golem-plugin` or `sm-feedback-plugin`; any 14-VALIDATION.md row that is pending, TBD or names a test that did not pass in this run; frontmatter without `nyquist_compliant: true` and `wave_0_complete: true`; a COVERAGE.md INTEGRATE row whose named test is missing or did not pass, or an OPT-OUT row without a reason; a security review row without a test. `--all` runs every stage except `--removal`.
(2) Planning docs (one commit in summercms.go, planning files only, Edit not Write for existing files): 14-VALIDATION.md gets real task ids (14-01-T1 … 14-06-T4) in its Per-Task Verification Map, the measured coverage numbers, status green per row, `status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`; REQUIREMENTS.md marks JOBS-02, JOBS-03, SRCH-02, INTG-01, INTG-02, API-08 and CLI-05 Complete in both the checklist and the traceability table; COVERAGE.md gets the named test per INTEGRATE row; `git mv` the two folded todos from pending to done.
(3) Run `bash scripts/check-phase14.sh --all` and record its final summary line in 14-VALIDATION.md.</action>
<verify>
<automated>bash scripts/check-phase14.sh --evidence &amp;&amp; bash scripts/check-phase14.sh --all &amp;&amp; grep -q 'nyquist_compliant: true' .planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md &amp;&amp; test -f .planning/todos/done/fetchguard-guarded-http-client.md &amp;&amp; test -f .planning/todos/done/redacting-slog-handler.md</automated>
<fails_when>Non-zero exit from --evidence or --all; the validation file lacks `nyquist_compliant: true`; either folded todo is not in .planning/todos/done.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE '^\| (JOBS-02|JOBS-03|SRCH-02|INTG-01|INTG-02|API-08|CLI-05) \| Phase 14 \| Complete' .planning/REQUIREMENTS.md` prints 7.
- `grep -cE '^run_evidence\(\)' scripts/check-phase14.sh` prints 1 and the `--all` branch calls run_evidence.
- `grep -cE '^\|.*\| ⬜ pending \|$' .planning/phases/14-domain-jobs-and-external-integrations/14-VALIDATION.md` prints 0.
- `ls .planning/todos/pending/ | grep -cE 'fetchguard-guarded-http-client|redacting-slog-handler'` prints 0.
</acceptance_criteria>
<done>Phase 14 has a green gate, a signed-off validation map, complete requirements and a decided API coverage matrix.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gate script → tracked source | The removal harness edits and restores tracked files |
| Gate verdict → phase sign-off | A false green would close the phase with gaps |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-37 | Repudiation | gate counts | medium | mitigate | Exact 175/172/3 counts from test output and manifest, zero-test and skip detectors, planted-input self-test (Task 1). |
| T-14-38 | Tampering | --removal harness | medium | mitigate | Refuses files with uncommitted changes, anchor-exact edits, cmp restore after each mutation, and runs only on its own flag, never inside --all (Tasks 1 and 3). |
| T-14-SC | Tampering | package installs | low | accept | No package installs; PHP truth tables run the existing PHP checkout. |
</threat_model>
<verification>
- `bash scripts/check-phase14.sh --all` green in summercms.go.
- Both plugin submodules pushed with no local commits ahead; fonoteka.go pointers committed.
</verification>
<success_criteria>
- Full unit coverage of all Phase 14 code; PHP truth tables for the Discogs pure classes.
- Every T-14 threat proven by a failing-without-protection test; gate, validation, requirements and API coverage evidence complete.
</success_criteria>
<output>
Create `.planning/phases/14-domain-jobs-and-external-integrations/14-06-SUMMARY.md` when done.
</output>

View File

@@ -570,15 +570,15 @@ if !ok {
| A8 | No FK or trigger links summer_jobs/river_job back to csv_imports (no deadlock under the row lock) | WR-02 | Lock ordering issue | | A8 | No FK or trigger links summer_jobs/river_job back to csv_imports (no deadlock under the row lock) | WR-02 | Lock ordering issue |
| A9 | The fetchguard client API shape (NewClient, modes, helpers, transport seam) | fetchguard | Plan-level design | | A9 | The fetchguard client API shape (NewClient, modes, helpers, transport seam) | fetchguard | Plan-level design |
## Open Questions ## Open Questions (RESOLVED)
1. **Is `GOLEM15_SSRF_ALLOWED_HOSTS` set in production?** We know the default allowlist rejects any user/org base_url outside `*.openai.com` (Anthropic included). The `.env` is secret-guarded. Recommendation: ask the user for the production value (names only) and make it the Go config default for the application; keep the PHP default in the plugin. 1. **Is `GOLEM15_SSRF_ALLOWED_HOSTS` set in production?** We know the default allowlist rejects any user/org base_url outside `*.openai.com` (Anthropic included). The `.env` is secret-guarded. Recommendation: ask the user for the production value (names only) and make it the Go config default for the application; keep the PHP default in the plugin. **DEFERRED (Phase 15 cutover):** 14-04 keeps the PHP default allowlist and honours the env override. The operator confirms the production value at cutover.
2. **Golem settings storage and key.** We know the PHP code is `golem_settings` with a plaintext `api_key` inside a repeater JSON, and cabana has no repeater. D-01 says `golem15_golem_settings`; D-03 says "encrypted" and "reading the existing settings row". Recommendation: confirm a dedicated `golem15_golem_models` table (encrypted key, admin list/form) plus an importer from `system_settings` item `golem_settings`; correct D-01's key string. 2. **Golem settings storage and key.** We know the PHP code is `golem_settings` with a plaintext `api_key` inside a repeater JSON, and cabana has no repeater. D-01 says `golem15_golem_settings`; D-03 says "encrypted" and "reading the existing settings row". Recommendation: confirm a dedicated `golem15_golem_models` table (encrypted key, admin list/form) plus an importer from `system_settings` item `golem_settings`; correct D-01's key string. **RESOLVED:** D-18 (user, 2026-10-03): the `golem15_golem_models` table with an encrypted key, plus an importer from `golem_settings`.
3. **Feedback settings storage.** The same convention question for `golem15_feedback_settings` (scalar, so a typed singleton table fits cabana). Confirm a typed table plus an importer. 3. **Feedback settings storage.** The same convention question for `golem15_feedback_settings` (scalar, so a typed singleton table fits cabana). Confirm a typed table plus an importer. **RESOLVED:** D-18: a typed singleton table plus an importer (14-05).
4. **Recording-proxy scope (D-15).** Building `summer parity:upstream` (MITM CA, script/forward modes) is the only faithful way to capture PHP's actual upstream requests. Confirm it is in scope, or accept hand-authored sidecars. 4. **Recording-proxy scope (D-15).** Building `summer parity:upstream` (MITM CA, script/forward modes) is the only faithful way to capture PHP's actual upstream requests. Confirm it is in scope, or accept hand-authored sidecars. **RESOLVED:** D-19 (user): `summer parity:upstream` is in scope (14-01).
5. **Phase 10.1 admin Discogs stubs** (`discogsLookup`, `discogsSync`) say "Phase 14 replaces". PHP has no such admin actions. In or out of scope? 5. **Phase 10.1 admin Discogs stubs** (`discogsLookup`, `discogsSync`) say "Phase 14 replaces". PHP has no such admin actions. In or out of scope? **RESOLVED:** out of scope. The stubs are left unchanged and 14-03 records this as an assumption.
6. **Reindex "already absent" message:** add `beachcomber.IndexDropper` (framework change) or accept one message for both outcomes? 6. **Reindex "already absent" message:** add `beachcomber.IndexDropper` (framework change) or accept one message for both outcomes? **RESOLVED:** `beachcomber.IndexDropper` and `EnsureIndex` are added in 14-01.
7. **Roadmap home for D-09 routes** (oauth-identities, `/api/v1/fonoteka/me`): Phase 15 requires all routes green; a todo exists, and a phase needs to be named. 7. **Roadmap home for D-09 routes** (oauth-identities, `/api/v1/fonoteka/me`): Phase 15 requires all routes green; a todo exists, and a phase needs to be named. **DEFERRED:** tracked in `.planning/todos/pending/orphan-pending-routes.md`. A phase must be inserted before Phase 15, for example with `/gsd-phase --insert 14.1`. The 14-06 gate pins exactly these 3 routes as pending.
## Environment Availability ## Environment Availability

View File

@@ -0,0 +1,87 @@
# API Coverage — Phase 14 (Discogs, Anthropic Messages, OpenAI-compatible chat completions, G15Office)
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
>
> Scope: Phase 14 is a parity port. The PHP reference (Płytarium's Golem15.Fonoteka, Golem15.Golem and Golem15.Feedback plugins) defines the surface. Every capability the PHP code calls is INTEGRATE. Every capability it never calls is OPT-OUT with the reason "not used by the PHP reference — parity port". The INTEGRATE reason column names the test that proves the capability. Plan 14-06 confirms each named test passes, and `scripts/check-phase14.sh --evidence` refuses a row whose test is missing or failing.
## Discogs API (api.discogs.com, i.discogs.com)
| capability | decision | reason |
|---|---|---|
| discogs: personal token auth header (Authorization: Discogs token=) | INTEGRATE | test: TestClientGetRelease (14-02) |
| discogs: GET /releases/{id} with curr_abbr | INTEGRATE | test: TestClientGetRelease, row-edit pick parity case (14-02) |
| discogs: GET /database/search by query (type=release) | INTEGRATE | test: TestDiscogsMatchRoute, albums/{id}/match parity cases (14-03) |
| discogs: GET /database/search by barcode (type=release) | INTEGRATE | test: TestDiscogsImportRoute, albums/import/discogs parity cases (14-03) |
| discogs: GET /masters/{id}/versions | INTEGRATE | test: TestDiscogsImportRoute (14-03) |
| discogs: GET /marketplace/price_suggestions/{id} | INTEGRATE | test: TestCoverPriceRoute, cover-price parity cases (14-03) |
| discogs: GET /oauth/identity (token check) | INTEGRATE | test: TestDiscogsCredentialTestRoute (14-03) |
| discogs: cover image download from Discogs image hosts | INTEGRATE | test: TestCoverFetcherHostLock, TestApplyReleaseModes (14-03) |
| discogs: rate-limit headers (X-Discogs-Ratelimit-Remaining, Retry-After) | INTEGRATE | test: TestDiscogsClientStatuses, TestRateLimiterSyncFromHeaders (14-02) |
| discogs: OAuth 1.0a request-token, authorize and access-token flow | OPT-OUT | not used by the PHP reference — parity port |
| discogs: GET /masters/{id} master detail | OPT-OUT | not used by the PHP reference — parity port (only master versions are read) |
| discogs: artist endpoints (/artists/{id}, /artists/{id}/releases) | OPT-OUT | not used by the PHP reference — parity port |
| discogs: label endpoints (/labels/{id}, /labels/{id}/releases) | OPT-OUT | not used by the PHP reference — parity port |
| discogs: release community rating endpoints | OPT-OUT | not used by the PHP reference — parity port |
| discogs: GET /marketplace/stats/{id} | OPT-OUT | not used by the PHP reference — parity port |
| discogs: marketplace listings, orders and fees | OPT-OUT | not used by the PHP reference — parity port |
| discogs: user collection folders and items | OPT-OUT | not used by the PHP reference — parity port |
| discogs: user wantlist | OPT-OUT | not used by the PHP reference — parity port |
| discogs: user lists, profile and submissions | OPT-OUT | not used by the PHP reference — parity port |
| discogs: inventory export and upload | OPT-OUT | not used by the PHP reference — parity port |
## Anthropic Messages API (api.anthropic.com/v1, or a configured base URL)
| capability | decision | reason |
|---|---|---|
| anthropic: POST /messages text completion with x-api-key and anthropic-version | INTEGRATE | test: TestAnthropicAdapterPayload (14-04) |
| anthropic: system prompt and the model system-prompt rule | INTEGRATE | test: TestAnthropicAdapterPayload, TestAIServiceFailures (14-04) |
| anthropic: image blocks from base64 data URLs | INTEGRATE | test: TestRecognizeAlbums, recognize parity cases (14-04) |
| anthropic: image blocks from plain URLs | INTEGRATE | test: TestAnthropicAdapterPayload (14-04) |
| anthropic: document blocks by file_id | INTEGRATE | test: TestAnthropicAdapterPayload (14-04) |
| anthropic: POST /files upload with the files-api beta header | INTEGRATE | test: TestAIServiceFailures files-endpoint cases (14-04) |
| anthropic: streaming responses (server-sent events) | INTEGRATE | test: TestAIServiceStream (14-04) |
| anthropic: usage and stop_reason parsing (max_tokens truncation) | INTEGRATE | test: TestRecognizeAlbums truncation case (14-04) |
| anthropic: error envelope (error.message) | INTEGRATE | test: TestAIServiceFailures (14-04) |
| anthropic: tool use | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: extended thinking | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: prompt caching (cache_control) | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: Message Batches API | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: token counting endpoint | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: models list endpoint | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: citations and search result blocks | OPT-OUT | not used by the PHP reference — parity port |
| anthropic: image generation | OPT-OUT | not used by the PHP reference — parity port (the PHP adapter has no image endpoint) |
## OpenAI-compatible chat completions (api.openai.com/v1, or a configured base URL)
| capability | decision | reason |
|---|---|---|
| openai: POST /chat/completions with a bearer key | INTEGRATE | test: TestOpenAIAdapterPayload, ai-credential/test parity cases (14-04) |
| openai: system message first in the message list | INTEGRATE | test: TestOpenAIAdapterPayload (14-04) |
| openai: image_url content parts | INTEGRATE | test: TestRecognizeAlbums, recognize parity cases (14-04) |
| openai: max_completion_tokens mapping and default reasoning_effort | INTEGRATE | test: TestOpenAIAdapterPayload (14-04) |
| openai: streaming responses (server-sent events) | INTEGRATE | test: TestAIServiceStream (14-04) |
| openai: POST /files upload | INTEGRATE | test: TestAIServiceFailures files-endpoint cases (14-04) |
| openai: POST /images/generations | INTEGRATE | test: TestAIServiceFailures image-model cases (14-04) |
| openai: usage and finish_reason parsing (length truncation) | INTEGRATE | test: TestRecognizeAlbums truncation case (14-04) |
| openai: error envelope | INTEGRATE | test: TestAICredentialTestRoute (14-04) |
| openai: per-credential base URL for compatible servers | INTEGRATE | test: TestSSRFGuard, TestAICredentialTestRoute unsafe and non-allowlisted cases (14-04) |
| openai: response_format structured output | OPT-OUT | not used by the PHP reference — parity port (PHP strips response_format before sending) |
| openai: tool and function calling | OPT-OUT | not used by the PHP reference — parity port |
| openai: Responses API | OPT-OUT | not used by the PHP reference — parity port |
| openai: Assistants API | OPT-OUT | not used by the PHP reference — parity port |
| openai: embeddings | OPT-OUT | not used by the PHP reference — parity port |
| openai: audio speech and transcription | OPT-OUT | not used by the PHP reference — parity port |
| openai: moderation | OPT-OUT | not used by the PHP reference — parity port |
| openai: fine-tuning | OPT-OUT | not used by the PHP reference — parity port |
| openai: batch API | OPT-OUT | not used by the PHP reference — parity port |
| openai: realtime API | OPT-OUT | not used by the PHP reference — parity port |
| openai: models list | OPT-OUT | not used by the PHP reference — parity port |
## G15Office support API (configured base URL)
| capability | decision | reason |
|---|---|---|
| g15office: bearer token auth | INTEGRATE | test: TestSyncG15Office (14-05) |
| g15office: POST /_support/api/v1/projects/{project}/tasks | INTEGRATE | test: TestSyncG15Office with the PHP-recorded job sidecar (14-05) |
| g15office: POST /_support/api/v1/tasks/{hashId}/attachments multipart | INTEGRATE | test: TestSyncG15Office with the PHP-recorded job sidecar (14-05) |
| g15office: task read, update, comment and status endpoints | OPT-OUT | not used by the PHP reference — parity port |

View File

@@ -91,8 +91,8 @@
], ],
"next": { "next": {
"command": "/gsd:progress --next", "command": "/gsd:progress --next",
"label": "Advance to the next step (verify)", "label": "Advance to the next step",
"reason": "Phase 13 of 21 · ready to verify" "reason": "Phase 14 of 21 · executing"
}, },
"updated_at": "2026-10-03T09:45:10.321Z" "updated_at": "2026-10-03T16:56:39.517Z"
} }