Skip to content

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)

  1. 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 explicit model=claude on claude-design is VALID (only contradiction errors).
  2. Server-side strikes: derive strikes from run history (failed design-lane runs matching ticket+capability via FleetDispatchReader); client priorFailures is advisory — may RAISE the count, never lower it. Log WHICH skip-list rule fired. Bias to escalate on uncertainty; ceiling is an absolute cap.
  3. 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.
  4. 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.
  5. Security hardening (all mandatory):
  6. 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.
  7. fileName sanitized (no CRLF/quotes/path chars) before Content-Disposition; never used in storage paths.
  8. Magic-byte sniffing per kind server-side; declared content-type alone is not trusted.
  9. Kestrel MaxRequestBodySize on the upload route (~28 MB for the base64 envelope of 20 MB); cap checked before decode.
  10. 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.
  11. Server derives disposition from kind; client input ignored. No enumeration/list endpoint.
  12. 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:

  1. 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.
  2. 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)

  1. Capability hard constraint — claude-design→claude. Contradicting explicit override is a 400 error (fail loud, don't remap).
  2. Explicit overriderouting.mode = "explicit"; caller may pin model, 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).
  3. Auto (default, ~95%) — capability default provider + tier ladder (below) + quota-aware model selection. routing omitted 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 " directive the executor honors when it can. The routing decision records intent either way; closing the gap needs a fleet-dispatch lane-contract change (tracked separately, not this repo).

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 }
}
→ 202 { 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-Token headers 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) → 201 ArtifactRecord. Size-capped (20 MB); id server-generated GUID.
  • GET /api/artifacts/{id}ArtifactRecord metadata (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}/downloadContent-Disposition: attachment for 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.
  • RunSummary gains artifactId? + artifactViewUrl? so a finished design run renders a "View design" chip.
  • Storage: %LOCALAPPDATA%/FleetMissionControl/artifacts/{id} + {id}.meta.json sidecar. 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

  1. Explicit override precedence vs capability constraint — 400 on contradiction (chosen) vs silent remap with warning?
  2. Tier ladder classification: keyword+hints heuristic in C# (chosen, deterministic/testable) vs asking a cheap model to classify (adds a call, non-deterministic)?
  3. Blocked-decision behavior: blocked+needsHuman (chosen) vs queue-and-wait for quota green?
  4. Artifact store local-to-API-host (chosen) vs shared OneDrive dispatch dir (multi-writer risk, sync lag)?