--- phase: 14-domain-jobs-and-external-integrations plan: 04 subsystem: ai tags: [sm-golem-plugin, golem15.golem, anthropic, openai, ai-service, ssrf, fetchguard, cabana, recognition, parity, upstream-sidecar, submodule] requires: - phase: 14-domain-jobs-and-external-integrations provides: "14-01 fetchguard.Client (TrustedMode, PublicOnlyMode, WithTransport), tide upstream sidecars and summer parity:upstream; 14-03 InboundLimits, phase14Routes and the per-case recording loop" - phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public provides: "ResolveAIConfig, AIAllowed, the AdminVisionModel seam and the BYOK credential tables" provides: - "New shared core plugin repo git.golem15.com/golem15/sm-golem-plugin (master pushed, cd3f0f6..c93e2d9), mounted in fonoteka.go at plugins/golem15/golem: plugin golem15.golem, package golem" - "golem AI layer: Prompt/Payload/AIResponse, OpenAI-compatible and Anthropic adapters over fetchguard (no SDK), AIService (Send, SendStream, SendFile, SendFilePath, SendFileReader, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask), PromptFactory" - "golem15_golem_models (encrypted write-only api_key) with its cabana list and form under golem15.golem.access_settings, the Settings lookups and golem:import-settings" - "security.AssertSafeURL / AllowedHosts / UnsafeURLError (SSRFGuard port, GOLEM15_SSRF_ALLOWED_HOSTS)" - "fetchguard.IsPrivateAddr (framework)" - "fonoteka: AIConfig.Trusted, SetAdminVisionModel/AdminVisionModel(ctx, db) fed by the golem vision model, UserAIConfig, guarded base_url overrides, RecognizeAlbums, AICredentialTest and AlbumRecognize on both groups" - "Parity: 20 PHP-recorded cases (7 ai-credential/test, 10 recognize JWT, 3 recognize token) with 12 upstream sidecars; ported 168, pending 3 (D-09)" affects: [14-05 sm-feedback-plugin (same submodule workflow), 14-06 unit tests and gate, 15 cutover (GOLEM15_SSRF_ALLOWED_HOSTS, golem:import-settings)] actuals: tokens: 90000 # chars/4 over the added lines of all three repos tasks: 4 commits: 1 # MEASURED in summercms.go: plan_head_before..plan_head_after (code only) app_repo_commits: 8 # MEASURED in fonoteka.go: app_repo_head_before..app_repo_head_after plugin_repo_commits: 3 # MEASURED in sm-golem-plugin: the whole new repo (pushed) plan_head_before: f519261da6fe62b7dc07ce842f4df8f4428ad14c plan_head_after: 7c2c43359fd460384848bb9cd9916906d2ef3cbc app_repo_head_before: 28349433d77ba16c0dec7c87e41ef486996169f2 app_repo_head_after: eea0b1607ef9b5d14b3a5186bf322e6eb45a4b9b plugin_repo_head_after: c93e2d9 tech-stack: added: [] patterns: - "A shared core plugin is born as an empty clone at its mount path, committed and pushed there, then registered with git submodule add; the application commits .gitmodules plus the gitlink alone, and later bumps as gitlink-only commits" - "Application tests that would change with a submodule bump are made bump-neutral first (filter the plugin's own history, tables and menu), so the bump commit stays a pure gitlink change and green" - "AI calls go through one AIService per app (services.ForApp); Trusted configs use fetchguard TrustedMode, every user or org config PublicOnlyMode after security.AssertSafeURL" - "PHP's raw-curl requests carry Accept: */* and no User-Agent; Go sends the same (an empty User-Agent value keeps net/http from adding its own)" - "Stateless app seams take the request's database (AdminVisionModel(ctx, db)), as 14-02's ReleaseFetcher does" key-files: created: - ../fonoteka.go/plugins/golem15/golem/ (whole repo: plugin.go, admin.go, README.md, config, lang, models, updates, controllers, console, classes/{valueobjects,providers,services,security,factories}, internal/pgtest) - ../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/golem_wiring.go - ../fonoteka.go/plugins/golem15/fonoteka/ai_routes_test.go - ../fonoteka.go/parity/upstream/scripts/{openai-chat-401,openai-chat-pong,anthropic-messages-pong,openai-recognize,openai-recognize-truncated,openai-recognize-garbage,anthropic-recognize,anthropic-recognize-truncated}.yaml - ../fonoteka.go/parity/fixtures/routes/files/spoofed.jpg modified: - modules/fetchguard/ip.go - modules/fetchguard/README.md - docs/services/outbound-http.md - ../fonoteka.go/.gitmodules - ../fonoteka.go/go.work - ../fonoteka.go/go.mod - ../fonoteka.go/summer.yaml - ../fonoteka.go/plugins.gen.go - ../fonoteka.go/app/app.go - ../fonoteka.go/plugins/golem15/fonoteka/go.mod - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go - ../fonoteka.go/plugins/golem15/fonoteka/routes.go - ../fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go - ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/inbound_limits.go - ../fonoteka.go/parity/{manifest.yaml,parity_test.go,parity_contract_test.go,fonoteka_seed_test.go,fonoteka_reset.php,php_parity.sh,check_corpus.go,README.md} key-decisions: - "ai-credential/test tests the caller's own stored credential only (PHP UserAiConfig::fromCredential), not the org or admin tier the plan named" - "AdminVisionModel takes the request's database: AdminVisionModel(ctx, db) and SetAdminVisionModel(func(ctx, db)); Boot installs a stateless reader of the golem vision model, Trusted set by the seam" - "The api package maps an AIConfig to the golem ModelConfig through (*classes.AIConfig).ModelConfig(), because the api package cannot import the plugin root" - "fetchguard.IsPrivateAddr is exported so the SSRF guard judges resolved addresses with the dial guard's table" - "GenerateImage keeps PHP's contract and does not fetch or check the returned URL; callers run security.AssertSafeURL (documented, tested)" - "An admin model with an empty base_url uses the provider default (PHP would post to /messages and fail to connect)" - "The admin api_key field is write-only: an update that sends it empty or redacted keeps the stored key" - "php_parity.sh exports GOLEM15_SSRF_ALLOWED_HOSTS empty so PHP records with the plugin default allowlist, which the Go replay uses" - "INTG-02 is not marked complete: 14-06 also declares it (requirements.ready-ids gate)" patterns-established: - "golem-vision and ai are additive seed extras on both sides, like discogs" - "Recorded multipart cases copy their part files into the specs directory (the recorder refuses symlinks)" requirements-completed: [INTG-02] coverage: - id: D1 description: "sm-golem-plugin exists, is pushed and mounted; golem15.golem loads before golem15.fonoteka" requirement: INTG-02 verification: - kind: unit ref: "plugins/golem15/golem#TestGolemPluginBoot; plugins/golem15/fonoteka#TestRouteTablePhase12/13/14" status: pass - kind: other ref: "git -C plugins/golem15/golem status --porcelain --branch → '## master...origin/master'; grep checks of .gitmodules, go.mod, summer.yaml, plugins.gen.go" status: pass human_judgment: false - id: D2 description: "OpenAI-compatible and Anthropic adapters and the full AIService with PHP's failure messages" requirement: INTG-02 verification: - kind: unit ref: "classes/providers#TestOpenAIAdapterPayload, TestAnthropicAdapterPayload; classes/services#TestAIServiceFailures, TestAIServiceStream; classes/factories#TestPromptFactory" status: pass human_judgment: false - id: D3 description: "AI models table, admin list and form, settings lookups with the default-model quirk, one-time importer" requirement: INTG-02 verification: - kind: unit ref: "plugins/golem15/golem#TestImportSettings, TestAdminModelsForm; classes/services#TestDefaultModelQuirk" status: pass human_judgment: false - id: D4 description: "SSRF guard port and the trusted/guarded split" requirement: INTG-02 verification: - kind: unit ref: "classes/security#TestSSRFGuard; classes/services#TestAdminModelTrusted; modules/fetchguard#TestIsPrivateAddr, ExampleIsPrivateAddr" status: pass human_judgment: false - id: D5 description: "AI tier resolution (admin vision model trusted, user and org guarded, no fallback) and ai-credential/test" requirement: INTG-02 verification: - kind: unit ref: "plugins/golem15/fonoteka#TestAICredentialTestRoute, TestAdminVisionTier, TestResolveAIConfigPrecedence" status: pass - kind: parity ref: "parity#TestParityCorpus/POST___fonoteka_api_v1_ai-credential_test_jwt (7 cases, 4 sidecars)" status: pass human_judgment: false - id: D6 description: "Album recognition on the JWT and token groups with PHP's answers" requirement: INTG-02 verification: - kind: unit ref: "plugins/golem15/fonoteka/classes#TestRecognizeAlbums; plugins/golem15/fonoteka#TestRecognizeRoutes" status: pass - kind: parity ref: "parity#TestParityCorpus/POST___fonoteka_api_v1_albums_recognize_jwt (10 cases), POST__api_v1_fonoteka_albums_recognize_personal_token (3 cases); TestParityCorpus/coverage 168 ported, 3 pending" status: pass human_judgment: false duration: 69min completed: 2026-10-03 status: complete --- # Phase 14 Plan 04: sm-golem-plugin and AI cover recognition Summary **A new shared plugin, sm-golem-plugin, brings the Golem AI layer to Go: SDK-free OpenAI-compatible and Anthropic adapters through the guarded client, the operator's AI models with an admin screen and a one-time importer, and the exact SSRF guard. On top of it a collector can test an AI key and photograph albums in the Nuxt app or through MCP, and gets PHP's answers, recorded from PHP and replayed offline.** ## Performance - **Duration:** 69 min - **Started:** 2026-10-03T20:27:58Z - **Completed:** 2026-10-03T21:37:08Z - **Tasks:** 4 - **Commits:** 3 in sm-golem-plugin (pushed), 8 in fonoteka.go, 1 code commit in summercms.go (plus this summary) - **Files:** 34 in the plugin (+4505), 105 in fonoteka.go (+2664/-176), 5 in summercms.go (+55) ## Accomplishments - **sm-golem-plugin (D-01).** The empty remote was cloned at `plugins/golem15/golem`, built, committed and pushed (`master` at `c93e2d9`, in sync with origin), then registered with `git submodule add`. fonoteka.go loads `golem15.golem` before `golem15.fonoteka`: `go.work`, both `go.mod` files, `summer.yaml`, the regenerated `plugins.gen.go` and `app.PluginIDs` name it. `golem15.fonoteka` requires it, and every test activation list names it. - **AI layer (D-02, D-04).** - `OpenAIAdapter` and `AnthropicAdapter` are hand-written JSON mappings, with no SDK. The OpenAI adapter maps `max_completion_tokens`, `response_format` and `reasoning_effort` as PHP does. The Anthropic adapter sets the `max_tokens` chain, builds image and document blocks and maps usage. - `AIService` covers every PHP call except faces and chat, with PHP's messages. - Requests go through `fetchguard`: `TrustedMode` for operator models and `PublicOnlyMode` for everyone else. They carry PHP curl's `Accept: */*` and no User-Agent. - **Models and admin (D-03, D-18).** `golem15_golem_models` keeps an encrypted, write-only `api_key` and has a cabana list and form under `golem15.golem.access_settings`. Its lookups keep PHP's default-model quirk. `golem:import-settings` copies the `golem_settings` repeater once and encrypts each key. - **SSRF (D-05, D-20).** `security.AssertSafeURL` ports SSRFGuard: https only, the allowlist (overridable with `GOLEM15_SSRF_ALLOWED_HOSTS`), and the resolve-time check through the new `fetchguard.IsPrivateAddr`. User and org `base_url` overrides are checked when they are resolved. A refused URL answers Winter's 500 page. - **AI tier (D-03).** `AIConfig.Trusted` is set only by the admin seam, which Boot fills from the golem vision model. A normal user never gets the admin model, and an org-locked member never falls back to it. - **Routes (INTG-02, D-07).** - `POST ai-credential/test` (JWT) tests an inline key or the caller's own stored key. - `POST albums/recognize` runs on the JWT group and on the token group (`inv.scope:ai`). The checks run in PHP's order: gate, limiter (10 per 60 s), validation, image guard, then the AI tier. It answers 200 with the albums, 200 with `recognition_truncated`, or 502. - `RecognizeAlbums` sends PHP's system prompt verbatim, with the collection's genre hint and the language directive. It retries once, then normalises the answer as PHP does. - **Parity.** 20 cases were recorded against PHP through `summer parity:upstream`, 12 of them with sidecars, covering both the OpenAI and the Anthropic exchanges. The cases include the admin tier through the global vision model, truncation on `length` and on `max_tokens`, an empty answer, a provider error, a JPEG polyglot refused before any upstream call, and the unsafe and non-allowlisted base URLs (500 page, no upstream). The corpus has 168 routes ported and passing and 3 pending (the D-09 routes). `check_corpus --require-recorded --check-secrets` is green. A mutation of one word in the prompt failed both recognize routes, which shows the upstream body is asserted. ## Task Commits sm-golem-plugin (`git@git.golem15.com:golem15/sm-golem-plugin.git`, master, pushed): 1. `cd3f0f6` feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send (Task 1) 2. `da9fcde` feat: security.AssertSafeURL ports the SSRFGuard (Task 2) 3. `c93e2d9` feat: AI models admin, settings import, Anthropic adapter and the rest of AIService (Task 2) fonoteka.go (not pushed): 1. `91b3f72` chore(14-04): mount sm-golem-plugin at plugins/golem15/golem (`.gitmodules` + gitlink only) 2. `bf90131` feat(14-04): the application loads golem15.golem before golem15.fonoteka (Task 1) 3. `cf18030` fix(14-04): check_corpus header scans stay on one line (Task 1 blocker) 4. `bbb641b` feat(14-04): a collector tests an AI key and gets PHP's answer through golem15.golem (Task 1) 5. `cce718a` test(14-04): fonoteka's migration, schema and admin menu checks ignore golem15.golem's own (Task 2) 6. `8ec5e34` chore(14-04): bump sm-golem-plugin (gitlink only, Task 2) 7. `72b6edc` feat(14-04): the AI tier is chosen as PHP chooses it and unsafe base URLs get PHP's 500 page (Task 3) 8. `eea0b16` feat(14-04): a collector photographs albums in the Nuxt app or through MCP and gets PHP's recognised album list (Task 4) summercms.go: - `7c2c433` feat(14-04): fetchguard.IsPrivateAddr exposes the dial guard's address classification (README and docs updated; TestDocsTree and docs:build --check pass) Tracer gate: after Task 1 the `` was re-run end to end (vet, the named tests with `-race`, the corpus, check_corpus, submodule status) and passed. The expansion tasks then went ahead. ## Deviations from Plan ### Auto-fixed issues **1. [Rule 3 - Blocking] `fetchguard.IsPrivateAddr` exported (framework)** - **Issue:** The plan's guard must use "fetchguard's classification", but `isReservedOrPrivate` was unexported, and a plugin cannot reach it. - **Fix:** Exported `IsPrivateAddr`, with a test, an example, README and `docs/services/outbound-http.md`. - **Commit:** 7c2c433 (summercms.go) **2. [Rule 1 - Bug] check_corpus flagged masked credential headers** - **Issue:** Once its `{{secret:ai-key}}` placeholder is stripped, `Authorization: Bearer` ran on through `\s+` into the next sorted header (`Content-Type:`). The same would happen to an unquoted `X-Api-Key`. - **Fix:** The patterns stay on one line (`[ \t]`). Two clean vectors were added to the test. - **Commit:** cf18030 **3. [Rule 3 - Blocking] The parity mismatch probe used ai-credential/test** - **Fix:** `assertPortedMismatch` now probes `GET oauth-identities`, a D-09 route that stays pending. - **Commit:** bbb641b **4. [Rule 3 - Blocking] Tests that pinned the plugin list** - **Issue:** `activateAppPlugins` asserted two plugins. Once golem has migrations, a menu and a table, the migration-status indexes, the history-table list, the schema snapshot and the admin navigation test would all change. - **Fix:** The activation-order check came first (in bf90131). The other checks were then made bump-neutral in cce718a: `withoutGolem`, the history-table filter, an `allowedDiffs` entry and `fonotekaCodes`. As a result the pointer bump `8ec5e34` changes only the gitlink and stays green, as the acceptance criterion requires. **5. [Rule 1 - Own omission] The part-file audit (T-12-17) rejected `spoofed.jpg`** - **Fix:** The audit allows the deliberate polyglot (it must not be an image). The fix was folded into eea0b16 before anything was pushed, so every commit is green. **6. [Rule 2 - Correctness] Parity recording environment** - `php_parity.sh` exports `GOLEM15_SSRF_ALLOWED_HOSTS=` (empty), so PHP uses the plugin default whatever the checkout's `.env` says. - `fonoteka_reset.php` forgets the `golem_settings` query cache before `Settings::set`. Otherwise Winter saves onto the deleted row (the first `admin-truncated` recording answered 403 because of this). ### Plan statements that conflict with PHP (parity kept) **7.** `ai-credential/test` tests the caller's own stored credential (PHP `UserAiConfig::fromCredential`), not "the stored user or org credential through ResolveAIConfig". **8.** `GenerateImage` does not run `AssertSafeURL` on the returned URL, because PHP's `generateImage` does not either; its caller does. The README says so, and `TestAIServiceFailures` checks the returned URL through the guard. ### Plan details refined **9.** `AdminVisionModel(ctx, db)` and `SetAdminVisionModel(func(ctx, db))` replace the db-less signature. The seam is stateless and serves every app in the process, which is the 14-02 ReleaseFetcher precedent. Trusted is set by the seam itself. **10.** The plan's `aiModelConfig` lives in `classes` as `(*AIConfig).ModelConfig()`, because the api package cannot import the plugin root. `golem_wiring.go` holds the vision-model source. **11.** `NewAIService(db func() *gorm.DB, cfg)` and `services.ForApp(app)` resolve the database per call. `ModelConfig` also carries `AcceptsImages`, `GeneratesImages` and `Position`, which the lookups and the default-model quirk need. `SendFileReader` uploads from a reader, which is the Go form of PHP's `sendFile(File)`. **12.** The genre hint is `GROUP BY name ORDER BY MAX(created_at) DESC LIMIT 20`, because Postgres rejects PHP's `DISTINCT` with `ORDER BY` on an unselected column. The recorded prompts match. **13.** No recorded 429 case for recognize. The Go replay keeps one app, and so one in-memory bucket, per route, so the JWT cases reach the limiter at most nine times. `TestRecognizeRoutes` asserts that the 11th call is 429. **14.** An admin model with an empty `base_url` uses the provider's default. PHP would build `"/messages"` and fail to connect. **15.** Extra tests beyond the plan: `TestAdminModelsForm` (write-only key through cabana's CRUD), `TestPromptFactory`, `TestIsPrivateAddr` and `ExampleIsPrivateAddr`. **16.** Commits land on `master` in both repos, as the sequential-executor instructions and the earlier Phase 14 plans do (`branching_strategy: none`). --- **Total deviations:** 16. Six are auto-fixes, two are plan conflicts resolved for parity, and eight are refinements. **Impact:** the only framework change is the small, documented `fetchguard.IsPrivateAddr`. There is no scope creep. ## Issues Encountered - The shell aliases `rm` to its interactive form, and one merge step waited on its prompt. Later steps use `command rm -f`. - A `pkill -f` whose pattern matched its own command line killed the shell. The PHP server was then stopped by PID. - The recordings write nothing into the PHP checkout: the AI routes store no files, and `git status` there shows only the user's own pre-existing files. ## Verification - sm-golem-plugin: `go vet ./...` and `go test ./...` pass, both standalone (`GOWORK=off`) and in the workspace. Every named test passes with `-race`. - fonoteka.go: `go vet ./...` passes, and `go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/golem/... -count=1` passes in every package. `go test ./... -short` passes. - Parity: `TestParityCorpus` reports 171/171 recorded, 168 passing, 0 failing and 3 pending. `TestCheckCorpusPortedCaseStatus`, `TestUpstreamSidecarsAreReplayed` and `TestParityContract` pass. `check_corpus --require-recorded --check-secrets` passes, with 0 pending case-status mismatches. - summercms.go: `go vet ./...` and `go test ./... -count=1` pass. `TestDocsTree` and `summer docs:build --check` pass. - Acceptance greps for all four tasks pass. `expectedPortedRoutes` is 168, `phase14Absent` is gone, and `inv.scope:ai` sits on the recognize line. The pointer-bump commit touches only `plugins/golem15/golem`, and the plugin README names no consuming application. ## Known Stubs None. ## Threat Flags None. The new surfaces are in the plan's threat register: the AI models admin (T-14-24, permission `golem15.golem.access_settings`, write-only encrypted key), the user/org base URLs (T-14-22), trusted mode (T-14-23) and the recognize route (T-14-25..27). No key, token or DSN literal was pushed (T-14-29: the diff was scanned before each push; test keys are short fakes). ## User Setup Required None for development. At cutover (Phase 15) the operator runs `golem:import-settings` once and confirms whether production needs `GOLEM15_SSRF_ALLOWED_HOSTS` (for example `.anthropic.com` for user Anthropic base URLs). This is research Open Question 1, still open. ## Next Phase Readiness - 14-05 can follow the same submodule bootstrap for sm-feedback-plugin: empty clone at the mount path, push, `git submodule add`, a gitlink-only commit, and bump-neutral app tests first. - 14-06 picks up the unit-test gate. INTG-02 is left Pending because 14-06 also declares it. --- *Phase: 14-domain-jobs-and-external-integrations* *Completed: 2026-10-03* ## Self-Check: PASSED Every listed file exists; commits 7c2c433 (summercms.go), 91b3f72, bf90131, cf18030, bbb641b, cce718a, 8ec5e34, 72b6edc and eea0b16 (fonoteka.go) and cd3f0f6, da9fcde and c93e2d9 (sm-golem-plugin, on origin/master) are present.