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

50 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
14-domain-jobs-and-external-integrations 04 execute 4
14-03
../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/
true
INTG-02
tokens raw_tokens tasks confidence
450000 450000 4 low
truths artifacts key_links prohibitions
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.
path provides contains
../fonoteka.go/plugins/golem15/golem/plugin.go golem.Plugin (golem15.golem) golem15.golem
path provides contains
../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go AIService with Send, SendStream, SendFile, SendFilePath, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask func (s *AIService) Send(
path provides contains
../fonoteka.go/plugins/golem15/golem/classes/security/ssrf_guard.go AssertSafeURL, AllowedHosts, UnsafeURLError GOLEM15_SSRF_ALLOWED_HOSTS
path provides contains
../fonoteka.go/plugins/golem15/golem/models/ai_model.go AiModel (golem15_golem_models) lagoon.Encrypted
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/album_recognition.go RecognizeAlbums, ErrRecognitionTruncated, RecognizedAlbum
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/golem_wiring.go ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go Boot calls classes.SetAdminVisionModel with golem's VisionModel mapped to AIConfig{Trusted: true} SetAdminVisionModel
from to via pattern
../fonoteka.go/plugins/golem15/golem/classes/services/ai_service.go summercms.go modules/fetchguard/client.go TrustedMode for admin models, PublicOnlyMode for user/org configs fetchguard.TrustedMode
from to via pattern
../fonoteka.go/summer.yaml ../fonoteka.go/plugins.gen.go summer build emits the blank import of git.golem15.com/golem15/sm-golem-plugin sm-golem-plugin
requirement_id category statement status verification
INTG-02 privacy 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 resolved test
requirement_id category statement status verification
INTG-02 transparency Recognition MUST NOT invent albums: when both AI answers are unparseable it returns [] (or recognition_truncated), never a guessed list resolved test

Phase Goal

ROADMAP Phase 14 goal (verbatim, not in user-story form): The domain-specific River jobs (CSV import write, Discogs match, wishlist digest), the reindex command, the Discogs client and AI cover recognition are ported on top of the Phase 11 jobs/realtime/search infrastructure and the Phase 13 API surface they serve.

This plan's slice: a collector 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).

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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md @.planning/phases/14-domain-jobs-and-external-integrations/14-RESEARCH.md @.planning/phases/14-domain-jobs-and-external-integrations/14-PATTERNS.md @.planning/notes/core-plugins-own-repos.md @.planning/notes/plugin-layout-winter-directories.md - 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).

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.
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 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. `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. ../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/ .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) 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). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/golem ./plugins/golem15/golem/classes/providers -count=1 -race -v -run '^(TestOpenAIAdapterPayload|TestGolemPluginBoot)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestAICredentialTestRoute|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets && git -C ../fonoteka.go/plugins/golem15/golem status --porcelain --branch <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> <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> 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.

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 `golem15_golem_models` becomes the plugin's table contract (D-18, user-decided). ../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 /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 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. go -C ../fonoteka.go vet ./... && 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)$' && go -C ../fonoteka.go test ./plugins/golem15/golem/... -count=1 && git -C ../fonoteka.go/plugins/golem15/golem status --porcelain --branch <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> <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> 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.

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 ../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/ ../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 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). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -race -v -run '^(TestAICredentialTestRoute|TestAdminVisionTier|TestResolveAIConfigPrecedence)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets <fails_when>Any command exits non-zero; a verbose run prints "--- FAIL", "no tests to run", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestAICredentialTestRoute, TestAdminVisionTier, TestResolveAIConfigPrecedence and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or case-status mismatch.</fails_when> <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> 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.

Task 4: A collector photographs albums in the Nuxt app or through MCP and gets PHP's recognised album list, including the truncation answer ../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 /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) 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). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/classes -count=1 -race -v -run '^(TestRecognizeAlbums|TestRecognizeRoutes|TestRouteTablePhase14|TestRouteTablePhase13|TestRouteTablePhase12)$' && go -C ../fonoteka.go test ./parity -count=1 -v -run '^(TestParityCorpus|TestCheckCorpusPortedCaseStatus|TestUpstreamSidecarsAreReplayed)$' && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets && go -C ../fonoteka.go test ./... -count=1 -short <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> <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> 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.

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

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