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 |
|
|
true |
|
|
|
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>
Artifacts this phase produces
(This plan's share.)
- Repo
git@git.golem15.com:golem15/sm-golem-plugin.git(master), modulegit.golem15.com/golem15/sm-golem-plugin, submoduleplugins/golem15/golem. - Package
golem:Plugin(IDgolem15.golem),AdminFS,AdminControllers,Permissions(golem15.golem.access_settings),Navigation,Commands. models:AiModel(tablegolem15_golem_models),Register,All. Migration202610030101_create_golem_models.classes/valueobjects:Prompt,Message,ContentPart,AIResponse,Usage.classes/providers:Adapterinterface,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); envGOLEM15_SSRF_ALLOWED_HOSTS. - fonoteka:
AIConfig.Trusted,SetAdminVisionModel,AdminVisionModel(func),RecognizeAlbums,RecognizedAlbum,ErrRecognitionTruncated,api.AlbumRecognize,api.AICredentialTest,InboundLimits.Recognize; routes JWTPOST /albums/recognize,POST /ai-credential/test, tokenPOST /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 addof the existing checkout, is the bootstrap path.
(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.
(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.
(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.
(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> |
<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>