ADR-105
nika:image_generate — the Media builtin rides a dedicated image plane
accepted · 2026-07-05 · L0 L1.5 L4 · cites 4
0ADR-105: nika:image_generate — the Media builtin rides a dedicated image plane #
Context #
Workflows increasingly need image ASSETS (OG covers, QR landing heroes, SEO
batches) as a step in a larger pipeline, not as a chat artifact. The stdlib
deferred the whole media class at the 42→22 consolidation; the spec kept a
§Media · DEFERRED placeholder. Meanwhile the two providers that matter
stabilized their image APIs (OpenAI gpt-image-2 · Gemini
gemini-3.1-flash-image, primary-source verified 2026-07-05), and the
engine already owns every primitive the pipeline needs: the kernel http
seam, the atomic-write fs seam, the permits.fs runtime boundary, base64 +
sha256 helpers, and a builtin dispatcher with injected effect seams. Two
transport facts force a design decision: the FETCH http client carries a 30s
idle-read guard that kills 60–120s image renders (the F1 timeout class), and
image endpoints are engine-fixed constants — never workflow data.
Decision #
Ship one official builtin, `nika:image_generate`, as a module family
inside `nika-builtin` (src/image/ · collapse-default rule — no new crate,
no 12-gate ceremony; feature-level gates still apply) and graduate the spec's
deferred Media class (23 → 24 canonical builtins).
- The image plane · provider calls ride a DEDICATED dispatcher seam —
image_http: Option<Arc<H>>+with_image_plane(http, keys), wired at the composition root with the PROVIDER-plane client (SSRFDisabledsanctioned: endpoints are const studio-fixed strings, same reasoning asinfer:· 600s transport ceiling). Unwired ⇒ real providers fail with the precise-002; the mock provider works without it.permits.net.httpdoes NOT govern the plane (likeinfer:);permits.tools+permits.fsDO. - Keys ·
ImageKeys { openai, gemini }(zeroizingSecrets) resolved ONLY at the composition root's sanctioned env boundary (NIKA_OPENAI_API_KEY → OPENAI_API_KEY·NIKA_GEMINI_API_KEY → GEMINI_API_KEY). The builtin never reads env, never logs a credential. - Assets, not blobs · V1 requires
save(default true) — images land underoutput_dir:(boundary-gated per FINAL path before any I/O ·NIKA-SEC-004), outputs and themanifest_version: 1provenance manifest carry paths + dimensions + sha256. Base64 NEVER rides outputs/logs/traces;debug: trueechoes a SANITIZED provider response (payloads stripped). - Validation is header-only · magic bytes are the authority (PNG IHDR ·
JPEG SOF · WebP VP8/VP8L/VP8X) — no pixel decode ⇒ no decompression-bomb
surface; dimension/byte bounds refuse absurd payloads (
-007). - Deterministic mock · a hand-rolled stored-deflate PNG encoder
(byte-stable forever · validated by the independent
pngdev-dep) makes the whole pipeline offline-testable, golden-grade. - Static surface · catalog row (21-arg vocabulary · required
prompt+output_dir) · model-facing ToolDef (drift-guarded against the catalog) ·builtin_shapeladder (V1 reservationsmode: edit/reference_images/save: falserefused loudly · closed enums · ranges · the transparent×jpeg conflict) ·BuiltinEffect::Fs { path_arg: "output_dir", writes, recursive }so escape-check AND--infer-permitsunderstand the recursive write (<dir>/**). - V1 boundaries stated, not faked ·
mode: edit,reference_images:,save: false, streaming, Imagen:predictare RESERVED with pointed errors + roadmap notes.
Consequences #
- The stdlib counts 24 (catalog · defs · schema enum · spec canon.yaml ·
conformance fixtures 004/005 · showcase
t1-og-images— cascade complete, pack re-vendored). n:on gemini = n sequential calls (no verifiedcandidateCountfor images) — documented, usage accumulates across calls.- The 64 MiB provider-response cap bounds
n × pngbatches — a clean-003hints jpeg/webp+compression. image_generation.*observability events ride the Emitter seam (stderr today; the run-stream integration follows the documentedEventKind::Extensionpath — FCI-009, unchanged here).- Run/workflow ids are honestly ABSENT from the manifest (no RunContext seam yet — roadmap, never empty-stringed).
- The 24th builtin crossed the agent router's activation threshold
(
RouterConfig::default().min_universewas 24): a plaintools: ["nika:*"]agent would have silently flipped from deterministic full-catalog passthrough to BM25 narrowing. The default is raised to 32 (routing pays for itself on MCP-heavy universes, never on the stdlib alone) and a composition-root test pinstool_defs().len() < min_universeso the next builtin addition fails loudly instead of silently re-crossing. - `provider_text` is a caption, not a payload channel: gemini's
interleaved text is clamped (2 000 chars · stable
provider_text_clamped:warning) and thedebug:echo clamps ANY oversized non-payload string — a multimodal response cannot carry megabytes of text intoToolResult.content/the agent's next turn/the manifest (review-swarm refutation, 2026-07-05). - v1.1 amendment (2026-07-05 · same-day) — the Socratic sovereignty
review found the V1 set violated supernovae-alignment Rule 3 (5 local
LLM providers · zero sovereign image path). The set opens: `local`
(one OpenAI-images-compat wire now covers LocalAI · Ollama · sd.cpp
sd-server· SGLang Diffusion · vLLM-Omni — landscape-verified; base URL = engine configNIKA_IMAGE_LOCAL_URL, default LocalAI:8080, optional Bearer;response_format: b64_jsonforced; url-only answers REFUSED — result URLs are never fetched; 300s default timeout; never inferred frommodel:) and `xai` (Imagine API ·grok-imagine-imagedefault,-qualitytier as the model knob · native aspect_ratio + resolution 1k/2k classes · usage billed in cost ticks → axes stay null). Riders:endpoint_hostprovenance in output+manifest ·count_shortfall:·revised_prompt_clamped:warnings ·format_mismatch:now fires only on an EXPLICITformat:(xai returns jpeg by design). BFL's cloud FLUX API stays deferred (async submit→poll wire — a second adapter architecture; the sovereign path to FLUX islocal). Mistral has no image-generation API (agent-tool only — verified negative). - v1.2 polish (2026-07-05 · same-day) — the top-of-art pass
(competitive research vs n8n/Dify/LangGraph/CrewAI/Make/ComfyUI ·
primary sources · Nika held 7/7 differentiators). Three additions:
(1) in-file provenance — saved PNG files carry a
nikatEXt chunk (deterministic core: tool · engine · provider · model · clamped prompt · seed — NO timestamp, so mock byte-determinism holds) inserted after IHDR before hashing, the ComfyUI/InvokeAI interchange practice no workflow engine ships; (2) honest cost —ProviderBatch.cost_usd(xai mapscost_in_usd_ticksexactly · 1¢ = 10⁸ ticks · others null-honest) rides output + manifest + the completed event, and the runtime meters ANY invoke tool's top-levelcost_usdinto the run ledger (the same channel infer rides); (3) doctor names the image plane — oneimagefinding (mock ready · which cloud keys present · the local URL). Identified-but-deferred:mode: edit/img2img (the one real competitor gap · reserved args already refuse loudly) · async submit-poll wires (video-class · not stills). - The image plane shares the dispatcher's `H` type parameter
(
Option<Arc<H>>) rather than adynseam:HttpPostDynis atrait_variantasync trait (not dyn-compatible), and the house composes generics everywhere — both planes arenika-httpclients with different configs. Accepted trade-off; a distinct provider-plane TYPE would need a boxing adapter (additive, if ever needed).
read at v0.107.0 · the decision record ships with the engine