FLT-19 — Capability routing + design/image viewer¶
Jira: FLT-102. "FLT-19" throughout this doc, the branch name, and commit messages is LOCAL board numbering — Jira's FLT-19 is an unrelated ticket (numbering collision, see docs/BOARD.md). The Jira record for this work is FLT-102.
Status: COUNCIL-APPROVED 2026-07-17 (5/5 unanimous on all four open questions; fable tier;
report llm-council/reports/2026-07-17_152718_review-this-api-routing-design-flt-19.md).
Branch wt/WT-0026/FLT-19-capability-routing.
Council amendments (binding for implementation)¶
- Structured 400 body on capability/override contradiction:
{ code: "capability_model_mismatch", capability, requiredModel, requestedModel, allowedModels }so the UI can disable illegal combos pre-submit. Redundant explicitmodel=claudeon claude-design is VALID (only contradiction errors). - Server-side strikes: derive strikes from run history (failed design-lane runs matching
ticket+capability via FleetDispatchReader); client
priorFailuresis advisory — may RAISE the count, never lower it. Log WHICH skip-list rule fired. Bias to escalate on uncertainty; ceiling is an absolute cap. - No queue: blocked+needsHuman stands; cockpit re-plan button re-hits /plan. Deferred execution only ever as explicit opt-in (waitForQuota + TTL + cancel) — never default.
- Artifact store: atomic write ordering = bytes → rename → meta sidecar (crash cannot leave meta without bytes). GC: TTL 14 days + total-disk budget. Single-host SPOF accepted.
- Security hardening (all mandatory):
- View CSP:
sandbox+default-src 'none'; script-src 'none'; base-uri 'none'; frame-ancestors 'none'belt-and-braces; REGRESSION TEST asserting no allow-token ever appears in the sandbox directive. fileNamesanitized (no CRLF/quotes/path chars) before Content-Disposition; never used in storage paths.- Magic-byte sniffing per kind server-side; declared content-type alone is not trusted.
- Kestrel MaxRequestBodySize on the upload route (~28 MB for the base64 envelope of 20 MB); cap checked before decode.
- Per-job upload token minted at dispatch (HMAC over jobId with the fleet key); uploads must present it AND reference an in-flight design run. The executing agent never holds the fleet-wide bearer. Cap artifacts per job.
- Server derives
dispositionfrom kind; client input ignored. No enumeration/list endpoint. - Artifact content is treated as non-sensitive fleet output; if that ever changes → short-lived signed view URLs.
Scope¶
Route NON-CLI capabilities (design/image generation via MCP tools: figma, stitch, claude-design, plus future image/video gen) and make every output viewable on any device. Distinct from the run_agent CLI balancer (FLT-18): this routes capabilities to a (provider, model, tier) and returns a viewable artifact.
Coordination record (FLT-16 / session B, 2026-07-17 — contract FROZEN)¶
FLT-16 reviewed the contract: shape agreed, disposition stays server-derived, RunSummary artifact fields are additive-safe (null unless a run actually produced an artifact — no placeholder URLs, ever). Two load-bearing constraints recorded:
- Land order: FLT-16 lands BEFORE FLT-19. The cockpit browser never holds the fleet bearer (bearer == key == fleet RCE); FLT-16's FleetAuthFilter accepts bearer OR a short-lived loopback-minted session token in the same X-Fleet-Auth header. FLT-19 reuses RequireFleetAuth (no hand-rolled auth), so it inherits browser-callable session-token auth the moment FLT-16 lands. Landing FLT-19 first would leave /api/design-runs uncallable from the actual cockpit UI.
- Frontend interceptor scope (owned by FLT-16): the cockpit session interceptor attaches the token only to /api/agent-runs; it must widen to /api/design-runs when this lands (one line in frontend/src/app/core/session.interceptor.ts). Artifact GETs are open reads — nothing needed there.
1. Capability model¶
| Capability | Providers | Constraint |
|---|---|---|
figma |
figma MCP driven by a harness agent | auto OR direct model call; default auto |
stitch |
stitch MCP driven by a harness agent | auto OR direct model call; default auto |
claude-design |
claude only | HARD: model MUST be claude. Explicit override to another model = 400, never silent remap. |
image |
image-gen (local when available) | same artifact/view contract; wired later |
2. Routing precedence (highest wins)¶
- Capability hard constraint — claude-design→claude. Contradicting explicit override is a 400 error (fail loud, don't remap).
- Explicit override —
routing.mode = "explicit"; caller may pinmodel,tier, or both. Whatever is unspecified falls through to auto. Explicit tier is honored even below the ladder's classification (user said so — logged in decision.reason). - Auto (default, ~95%) — capability default provider + tier ladder (below) + quota-aware
model selection.
routingomitted entirely ⇒ auto.
3. Tier ladder (the auto-tier brain)¶
Encodes CLAUDE.md's council-approved escalation ladder (2026-07-08). Today NOTHING enforces it
(usage-governor ModelWeights.cs = cost weighting only; AgyPolicy.cs hardcodes "sonnet").
New TierLadder = pure static classifier, deterministic, unit-testable.
Abstract tiers: mechanical | standard | ceiling. Each routable model family maps tier→concrete
model (claude: haiku/sonnet/fable · ollama: qwen3:8b for mechanical/standard, no ceiling ·
gemini/codex/grok: flash-vs-pro-class equivalents where the CLI exposes them, else single).
ConcreteModel enforcement scope (review-gate ruling 2026-07-17): enforced for the ollama
lane (its payload carries the model); ADVISORY for the CLI lanes — the frozen fleet-dispatch
lane commands have no per-invocation model flag, so the payload carries a "run as
Strike scope: strikes are TICKET-WIDE across the design lane (not per-capability): RunSummary carries no capability, and a failed design run on the ticket is signal for the next attempt regardless of tool. Escalation errs upward by design.
Classification of the task/prompt + structured hints:
- ceiling (skip-list, checked FIRST): architecture/system design/core abstractions/
cross-cutting refactor; security-sensitive (auth, crypto, secrets, permissions, validation,
data-loss); unknown root cause/tricky debugging; council synthesis/review gates; writing
specs/prompts for other agents; concurrency/distributed/migrations/financial/legal;
ambiguous spec; prior failed attempt. Hints: securitySensitive, crossModule,
estimatedFiles > 3, ambiguous, priorFailures > 0.
- mechanical: search, summaries, renames, boilerplate, lint, 1–2 file mechanical edits,
test fixtures, doc moves — matched only when NO skip-list signal present.
- standard: everything else (default).
Strike escalation: strikes = priorFailures. 1 strike ⇒ +1 tier. ≥2 strikes ⇒ jump to
ceiling. Escalation only ever goes UP.
Never auto-loosen (guardrail): quota pressure NEVER lowers a tier. If the ladder says
ceiling and every ceiling-capable pool is locked, the decision returns blocked +
needsHuman: true — not a silent sonnet downgrade. Quota may steer the MODEL choice among
tier-adequate candidates (e.g. mechanical→ollama when claude RED), never the tier.
4. Quota-aware model pick (auto mode)¶
Candidates = models valid for (capability, tier). Rank by QuotaReader pool state
(stubbed pending FLT-15): prefer GREEN > YELLOW > UNKNOWN > RED; RED/locked pools excluded
unless capability-pinned (claude-design: claude RED ⇒ decision degrades to blocked,
surfaced to user). Decision carries per-candidate quota estimates:
{model, tier, quotaPoolId, quotaTier, events5h, stale} — cockpit renders them next to the
launch button.
5. API surface¶
POST /api/design-runs (RequireFleetAuth)¶
{
"capability": "figma|stitch|claude-design|image",
"prompt": "…",
"target": "PCNAME",
"ticket": "FLT-19",
"routing": { "mode": "auto|explicit", "model": "claude|gemini|codex|grok|ollama", "tier": "mechanical|standard|ceiling" },
"hints": { "securitySensitive": false, "crossModule": false, "estimatedFiles": 0, "ambiguous": false, "priorFailures": 0 }
}
{ jobId, target, capability, decision, artifact: null }; outcome via hub + GET /runs
(same async pattern as /agent-runs). Execution = file-queue dispatch (kind run_agent,
agent = routed model family, task = capability instructions incl. MCP tool use). No new
transport; the council-mandated file queue stays the only write path to the fleet.
POST /api/design-runs/plan (RequireFleetAuth; no dispatch)¶
Same body → 200 RoutingDecision only. Lets the cockpit preview routing + quota before launch.
RoutingDecision¶
{
"capability": "stitch", "mode": "auto",
"model": "claude", "tier": "standard", "concreteModel": "sonnet",
"status": "routed|blocked",
"needsHuman": false,
"reason": "auto: no skip-list signals; claude GREEN",
"candidates": [ { "model": "claude", "tier": "standard", "quotaPoolId": "native-claude", "quotaTier": "GREEN", "events5h": 3, "stale": false } ]
}
Artifact contract (coordinated with fmc-wt-ui / session B)¶
- POST /api/artifacts (NOT RequireFleetAuth — auth is the per-job upload token minted at
dispatch, sent as
X-Job-Id+X-Upload-Tokenheaders and verified BEFORE the body is read; the executing agent never holds the fleet bearer) — executing agent uploads output. JSON{ kind, contentType, fileName, contentBase64 }(capability/model/ticket/jobId come from the registered run, never the body) → 201ArtifactRecord. Size-capped (20 MB); id server-generated GUID. - GET /api/artifacts/{id} →
ArtifactRecordmetadata (open read, tailnet perimeter). - GET /api/artifacts/{id}/view → bytes inline. HTML/SVG only. Served with
Content-Security-Policy: sandbox(opaque origin: model-generated HTML cannot reuse the cockpit origin) + nosniff (global). Cockpit opens this URL in a NEW browser tab/window — works on phone, fold, desktop and the VS/VSCode/Antigravity embedded WebView2 cockpits. - GET /api/artifacts/{id}/download →
Content-Disposition: attachmentfor binary/image. - ArtifactRecord
{ "id": "guid", "kind": "html|svg|image|binary", "contentType": "text/html", "fileName": "design.html", "capability": "stitch", "model": "claude", "ticket": "FLT-19", "jobId": "…", "createdAt": "ISO", "sizeBytes": 12345, "disposition": "view|download", "viewUrl": "/api/artifacts/{id}/view", // null when disposition=download "downloadUrl": "/api/artifacts/{id}/download" } - Disposition rule (brief, verbatim intent): HTML/SVG ⇒ view (new tab); binary/image ⇒ download.
RunSummarygainsartifactId?+artifactViewUrl?so a finished design run renders a "View design" chip.- Storage:
%LOCALAPPDATA%/FleetMissionControl/artifacts/{id}+{id}.meta.jsonsidecar. API host is the single viewer origin (tailnet-reachable from every device).
6. Security notes¶
- Writes carry the fleet bearer / cockpit session token (existing FleetApiAuth) with ONE exception: POST /api/artifacts authenticates with the per-job upload token headers (X-Job-Id + X-Upload-Token) instead — the executing agent never holds the bearer, and FleetApiAuth must NOT be applied to that endpoint. Reads stay open behind tailnet.
- Artifact ids validated as GUID before any path use (no traversal).
- View endpoint: CSP sandbox; no
allow-same-origin. Auth is a custom header (not a cookie) so sandboxed HTML has no ambient authority against the write surface anyway; sandbox is belt-and-braces. - Upload caps: 20 MB/artifact; content-type allowlist per kind (html→text/html, svg→image/svg+xml, image→image/png|jpeg|webp, binary→application/octet-stream).
- claude-design constraint enforced server-side (routing), not trusted from the client.
7. Open questions for council¶
- Explicit override precedence vs capability constraint — 400 on contradiction (chosen) vs silent remap with warning?
- Tier ladder classification: keyword+hints heuristic in C# (chosen, deterministic/testable) vs asking a cheap model to classify (adds a call, non-deterministic)?
- Blocked-decision behavior:
blocked+needsHuman (chosen) vs queue-and-wait for quota green? - Artifact store local-to-API-host (chosen) vs shared OneDrive dispatch dir (multi-writer risk, sync lag)?