Skip to content

Engine HowTos — Setup, Drive Paths & Validation ​

How to point Weftra at any coding-agent backend — Claude, Gemini, OpenAI Codex, GitHub Copilot, a local Ollama/vLLM model, or OpenHands — plus the per-engine auth and the live-run validation playbook. For a fast copy-paste cookbook and the full QA matrix, see QA_GUIDE.md.

Spec 048 — Declarative engine profiles.

Weftra is a neutral orchestrator: it speaks Agent Client Protocol (ACP) over stdio and routes work to whichever agent binary the operator declares. No vendor SDK lives in Weftra's code (constitution §VII). This document records, per candidate engine, the ACP/headless drive path an operator uses, the auth the engine expects, and known durability caveats. It also provides the human live-run playbook an operator follows when validating a new vendor backend.


1. ACP drive model recap ​

Every non-Claude backend uses the generic AcpEngine path:

Weftra → runtime.wrap(cmd, args) → spawn subprocess → JSON-RPC 2.0 / stdio

The subprocess speaks ACP (initialize → session/new → session/prompt → session/update notifications → final session/prompt response). Weftra never inspects what the subprocess is; it just follows the protocol.

A profile is declared in FA_ENGINE_PROFILES:

json
[
  {
    "id":      "gemini",
    "cmd":     "gemini-cli",
    "args":    ["--acp"],
    "authEnv": { "GOOGLE_API_KEY": "AIza..." }
  }
]

A profile may also declare probeEgressHosts — the bare DNS host names an admin's account check for this engine may connect to (see "Checking an engine account" below):

json
[
  {
    "id":               "fake-acp",
    "cmd":              "fake-acp-cmd",
    "authEnv":          { "FAKE_TOKEN": "..." },
    "probeEgressHosts": ["api.fake-acp.example"]
  }
]

probeEgressHosts is optional: 1 to 8 entries, each a bare DNS host name (letters, digits, hyphens and dots; no scheme, port, path, wildcard, whitespace or IP address). Entries are lower-cased and de-duplicated. Anything else fails Weftra's startup, naming the profile and the field. It is operator configuration, read from FA_ENGINE_PROFILES on the Weftra host only — no request, project or feature can set it. Weftra names no vendor and no host here; the example above is invented.

checkFailureHold is optional. It is the operator's rule for when repeated unanswered checks hold an account of this engine, and it requires probeEgressHosts (a profile without check hosts can never be checked, so the rule could never apply). Exactly two keys: after, an integer from 2 to 10, and minutes, an integer from 15 to 1440. Anything else fails Weftra's startup, naming the profile and the field:

json
[
  {
    "id":               "fake-acp",
    "cmd":              "fake-acp-cmd",
    "authEnv":          { "FAKE_TOKEN": "..." },
    "probeEgressHosts": ["api.fake-acp.example"],
    "checkFailureHold": { "after": 3, "minutes": 60 }
  }
]

With that rule, the third admin-requested check in a row that does not answer PONG holds the account for 60 minutes (check_failed), counted from that check. A PONG, a Reset health, or an edit to the account ends the count. The hold makes the account unselectable until it expires, and it does not move a paused run. The full behaviour is in docs/OPERATIONS.md §34a ("Holding an account after repeated unanswered checks"), and the operator's view is in docs/USER_GUIDE.md §"Engine accounts". The after: 2 floor exists because checks of one account are at least 15 minutes apart, so one transient failure never holds an account. Like the rest of the profile, the field is read from FA_ENGINE_PROFILES on the Weftra host only. The example above is invented.

Declaring the rule also lets a run's failed engine call start one of these checks (spec 457 inc-2d2b2). When a run's main call to this engine throws, Weftra checks the account that run used, at most once per account per 15 minutes, the same window an admin's Check uses. One failed call never holds the account on its own: the check's answer decides. That also means failing runs decide when the checks that count toward the hold happen. A check that lands while the account is throttled or briefly unreachable is unanswered like any other, and the hold then applies to every project drawing from the account, so pick after and minutes with a shared account in mind. Without the rule, a failed run starts no check for this engine. The cost is one small engine call per check, on the operator's account. The trigger table and its exclusions are in docs/OPERATIONS.md §34a ("Run-started checks").

Weftra registers the profile as a named engine. The project or feature's engine field selects it. The subprocess env for any non-Claude engine is built from nothing — not a copy of Weftra's process.env. The child receives only a minimal base allowlist (PATH, HOME, TMPDIR, LANG, LC_ALL) plus the profile's declared authEnv. Weftra host secrets (GITHUB_TOKEN, ADMIN_API_KEY, SESSION_SECRET, ANTHROPIC_API_KEY) are absent by construction on every runtime — DockerRuntime and LocalRuntime alike (spec 119). Under DockerRuntime, authEnv values travel via the spawn env with name-only -e KEY docker flags, so secret values never appear in host process argv. Note the container also carries the runtime image's own baked-in env — the guarantee is "no Weftra host secrets and nothing beyond base + authEnv from Weftra", not an empty environment.

Smoke-test caveats (read before trusting diagnose:engine):npm run diagnose:engine always uses LocalRuntime regardless of the RUNTIME setting — it does not exercise the DockerRuntime container env seam. The anthropicStripped:true flag is inferred from ENV:KEY=absent lines the engine happens to emit; a real codex exec or gemini CLI prints none, so the flag is true by absence of evidence, not confirmed measurement. The CI test suite uses a mock CLI that explicitly echoes env keys — that is the verifiable isolation check. Use diagnose:engine as a quick operational sanity check, not as a proof of credential isolation.

Tool-event observability parity (spec 328). AcpEngine's session/update handler now maps the real ACP tool_call / tool_call_update notifications to tool_use/tool_result RunEvents carrying toolCallId (the correlation key between a call and its update(s)/result) and status (the pending → in_progress → completed/failed lifecycle), in addition to the existing name/inputPreview/preview fields — at parity with claude-code's tool_use↔tool_result correlation (spec 250). Both fields are optional on the RunEvent shape, so any renderer (live-log view, run-event ledger, provenance) that already displays a claude-code run's tool events shows an ACP run's tool lifecycle the same way — no engine-dependent thinning. Weftra's own flat-mock/shim adapters ({toolName}/{toolResult}) are unaffected: they never carry toolCallId/status and continue to map exactly as before. Tool input/output is still redacted before it reaches any surface, same as every other captured string (spec 219) — richer capture never bypasses redaction.

Permission-reply wire shape (spec 416). A spec-conformant session/request_permission request carries an options[] array (each { optionId, kind, name? }, kind one of allow_once / allow_always / reject_once / reject_always) and expects a reply naming one of them — { outcome: { outcome: 'selected', optionId } } or { outcome: { outcome: 'cancelled' } }. AcpEngine selects allow_once for an allow decision and never allow_always — a standing grant would let the agent skip the resolver for every later call of the same tool in the run, silently hollowing out governed/ask; if the agent offers no per-call allow_once option, Weftra replies cancelled (fails closed) rather than grant more than the policy decided. A deny decision selects reject_once, falling back to reject_always, then cancelled. The selection logic is the pure, exported selectPermissionOutcome(allow, options) in acp-engine.ts. The flat { allow: true | false } reply is a legacy path, sent only when a request carries no options at all — this is what Weftra's own reference shims (adapters/gemini-acp, adapters/codex-acp) and tests/fixtures/mock-acp-agent.mjs speak by default. It is a compatibility shim for Weftra's existing adapters, not a second protocol: a new shim or adapter must send spec-shaped options, not rely on the flat reply.


2. Candidate engine drive paths ​

2.1 Gemini CLI (Google Generative Language / Vertex AI) ​

Shape B — reference ACP shim (adapters/gemini-acp/). Turnkey as of spec 117 inc-1 (2026-07-18).

Investigation finding: The Google Gemini CLI (@google/gemini-cli, command: gemini) does not natively speak Weftra's ACP (JSON-RPC 2.0 / stdio) protocol — it has no initialize / session/new / session/prompt handshake. It does support non-interactive print mode (gemini -p "prompt"). The reference shim at adapters/gemini-acp/index.mjs wraps that interface and exposes it as ACP. The vendor's agentic loop is rented, not reimplemented (constitution §VII).

Prerequisites:

bash
npm install -g @google/gemini-cli    # installs `gemini` on PATH
gemini --version                     # confirm; tested against ≥ 1.5

FA_ENGINE_PROFILES recipe (copy-paste):

API key (Google AI Studio):

json
{
  "id":      "gemini",
  "cmd":     "node",
  "args":    ["/abs/path/to/adapters/gemini-acp/index.mjs"],
  "authEnv": { "GOOGLE_API_KEY": "AIza..." }
}

Vertex AI (service account / Application Default Credentials):

json
{
  "id":      "gemini",
  "cmd":     "node",
  "args":    ["/abs/path/to/adapters/gemini-acp/index.mjs"],
  "authEnv": {
    "GOOGLE_APPLICATION_CREDENTIALS": "/run/secrets/gcp-sa.json",
    "GOOGLE_CLOUD_PROJECT":  "my-gcp-project",
    "GOOGLE_CLOUD_LOCATION": "us-central1"
  }
}

After restarting Weftra, validate:

bash
curl http://localhost:3100/api/engines      # → "gemini" must appear in engines[]
npm run diagnose:engine -- gemini          # → PASS, anthropicStripped: true

See adapters/gemini-acp/README.md for full setup instructions.

Durability note: The shim depends on the gemini -p / --model flags. Pin the CLI version in your environment (@google/gemini-cli@<version>) and retest when upgrading.


2.2 OpenAI Codex CLI ​

Shape B — reference ACP shim (adapters/codex-acp/). Turnkey as of spec 117 inc-2 (2026-07-18).

Investigation finding: The OpenAI Codex CLI (@openai/codex, command: codex) does not natively speak Weftra's ACP (JSON-RPC 2.0 / stdio) protocol — it has no initialize / session/new / session/prompt handshake. It does support non-interactive execution mode (codex exec <prompt>). The reference shim at adapters/codex-acp/index.mjs wraps that interface and exposes it as ACP. The vendor's agentic loop is rented, not reimplemented (constitution §VII).

Prerequisites:

bash
npm install -g @openai/codex    # installs `codex` on PATH
codex --version                 # confirm; tested against ≥ 0.1

FA_ENGINE_PROFILES recipe (copy-paste):

API key (OpenAI platform):

json
{
  "id":      "codex",
  "cmd":     "node",
  "args":    ["/abs/path/to/adapters/codex-acp/index.mjs"],
  "authEnv": { "OPENAI_API_KEY": "sk-proj-..." }
}

Org-scoped usage:

json
{
  "id":      "codex",
  "cmd":     "node",
  "args":    ["/abs/path/to/adapters/codex-acp/index.mjs"],
  "authEnv": {
    "OPENAI_API_KEY": "sk-proj-...",
    "OPENAI_ORG":     "org-..."
  }
}

After restarting Weftra, validate:

bash
curl http://localhost:3100/api/engines      # → "codex" must appear in engines[]
npm run diagnose:engine -- codex           # → PASS, anthropicStripped: true

See adapters/codex-acp/README.md for full setup instructions.

DockerRuntime path note (default RUNTIME=docker): Weftra runs the engine cmd inside the feature-workspace container (docker exec -w /work <cid> node <path>), but only the workspace directory is bind-mounted at /work — the fa-runtime image does not include adapters/codex-acp/. To use this shim under DockerRuntime, either (a) bake it into the image (COPY adapters/ /adapters/ in the Dockerfile and update args to /adapters/codex-acp/index.mjs), or (b) bind-mount the adapter directory into the container and update args accordingly. For local development only, RUNTIME=local also works — the env-from-nothing isolation guarantee (§1) still holds, but the vendor CLI runs unsandboxed on the Weftra host filesystem, and Weftra only starts in local mode with FA_LOCAL_RUNTIME_ACKNOWLEDGED=host-unsandboxed set (see docs/OPERATIONS.md §39). The same gap applies to adapters/gemini-acp/ (inc-1); a systemic fix (baking adapters into the image) is tracked separately.

Durability note: The shim depends on codex exec accepting the task as a positional argument and the --model flag being stable. Pin the CLI version in your environment (@openai/codex@<version>) and retest when upgrading.


2.3 GitHub Copilot CLI ​

Target: GitHub Copilot models (GPT-4o Copilot) via the local gh copilot extension CLI.

ACP drive path:

cmd:  gh
args: ["copilot", "--acp"]

This drives the local CLI agent (gh extension install github/gh-copilot), not the remote Copilot cloud agent. The remote cloud agent uses user OAuth tokens and cannot be meaningfully sandboxed per-run; the local CLI is the stable seam Weftra targets.

Auth: Copilot access is attached to the authenticated GitHub user. The CLI reads credentials from GH_TOKEN or the gh credential store:

json
"authEnv": { "GH_TOKEN": "gho_..." }

The token must have the copilot scope. If using the gh credential store (host-mounted creds), no authEnv is needed, but the ~/.config/gh directory must be available inside the runtime container.

Smoke-probe caveat (spec 395 / RM-181): the engine smoke probe (npm run diagnose:engine / POST /api/admin/engines/:id/smoke-test) hands the vendor CLI a scratch HOME/XDG_CONFIG_HOME/XDG_DATA_HOME/XDG_STATE_HOME/ XDG_CACHE_HOME instead of the operator's, so a profile relying on the hostgh credential store will no longer authenticate under the probe: gh resolves hosts.yml under $XDG_CONFIG_HOME, which the probe points at a throwaway directory, and the run fails on a vendor auth error (probeHomeIsolated stays true — the isolation held; it is the credential that is missing). This is intended. The real agent run does not get the operator's $HOME either, so a probe that did was reporting a pass the run could not reproduce. A Copilot profile that needs to pass the smoke test must declare authEnv: { "GH_TOKEN": "gho_..." } explicitly rather than depending on the host credential store.

Do not work around this by declaring HOME (or an XDG_* var) in authEnv: authEnv is applied after Weftra's overlay (buildSubprocessEnv in src/services/engine/subprocess-env.ts), so such a key points the vendor back at the operator's credential store. Weftra detects exactly that — the probe compares every overlay key against the engine's declared authEnv key names and against any ENV: line the agent reports (assessProbeIsolation in src/services/engine/smoke-test.ts) — reports probeHomeIsolated: false, and the CLI treats it as a FAIL with a non-zero exit (the pass computation in src/tools/diagnose-engine.ts).

Durability caveat: gh copilot --acp is a proposed interface. As of 2026-07 the ACP flag is not yet in the stable gh-copilot extension release. Watch the github/gh-copilot release notes. The user-token-only cloud agent is unsuitable for Weftra (no per-run isolation, no headless mode).


3. Human live-run validation playbook ​

One-command shortcut (P2 exit): Before stepping through this playbook manually, try the engine smoke-test CLI:

bash
npm run diagnose:engine -- <engineId>

This validates that the declared engine drives end-to-end and that Anthropic credentials are isolated — in a single command. See docs/OPERATIONS.md §"Validating a declared engine profile" for details. Use the full manual playbook below when you need to validate a complete feature run (clone → implement → PR), not just the protocol handshake.

This is the mandatory human step before declaring a vendor backend production-ready. CI tests prove Weftra's protocol and auth isolation with a mock agent; this playbook proves a real vendor agent completes a real Weftra feature run end-to-end.

Prerequisites:

  • Weftra running locally or on a dev server (any autonomy mode)
  • Real vendor credentials for the engine being validated
  • A test Git repository Weftra can clone and push to
  • FA_ENGINE_PROFILES set with the real credential in authEnv

Step 1 — Declare the profile ​

Set FA_ENGINE_PROFILES before starting Weftra:

bash
export FA_ENGINE_PROFILES='[{
  "id":      "gemini-live",
  "cmd":     "gemini-cli",
  "args":    ["--acp"],
  "authEnv": { "GOOGLE_API_KEY": "AIza...REAL...KEY" }
}]'

Restart Weftra. Verify the profile is registered:

bash
curl http://localhost:3100/api/engines
# → { "engines": ["claude-code", "acp", "gemini-live"], "default": "claude-code" }

Step 2 — Create a test project targeting the new engine ​

bash
curl -X POST http://localhost:3100/api/projects \
  -H 'Content-Type: application/json' \
  -d '{
    "name":          "vendor-validation-project",
    "repo_url":      "https://github.com/you/test-repo.git",
    "autonomy_mode": "full_auto",
    "engine":        "gemini-live"
  }'
# Save the returned api_key as PROJECT_KEY.

Step 3 — Submit a minimal feature ​

bash
curl -X POST http://localhost:3100/api/features \
  -H "Authorization: Bearer $PROJECT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "title":       "vendor-validation: add hello-world",
    "description": "Add a file hello.txt containing the string VENDOR_VALIDATED."
  }'
# Save the returned feature id as FEATURE_ID.

Step 4 — Observe the run ​

Watch the live log:

http://localhost:3100/logs.html?id=<FEATURE_ID>

Or poll the feature status:

bash
watch -n 5 curl -s http://localhost:3100/api/features/$FEATURE_ID | jq .status

Expected progression: pending → queued → in_progress → implemented (or failed if the vendor agent errors — inspect the log).


Step 5 — Confirm sandbox and auth integrity ​

While the run is in in_progress, verify:

  1. Sandbox seam held: the agent process was spawned via runtime.wrap() (check the feature log for the vendor agent command, not a direct invocation).

  2. Subprocess env built from nothing (spec 119): Weftra constructs the child env from an explicit base allowlist + authEnv only — no copy of process.env. Neither ANTHROPIC_API_KEY nor Weftra host secrets (GITHUB_TOKEN, ADMIN_API_KEY, SESSION_SECRET) are forwarded to the vendor command on any runtime. The smoke test asserts this: npm run diagnose:engine -- <engineId>. What this proves: Weftra's own secrets and ANTHROPIC_* are absent from the vendor subprocess (anthropicStripped, parentSecretsAbsent). What it does not prove: that the vendor had no other credential to fall back on — see the next item.

  3. Vendor auth used — conditioned on probeHomeIsolated (spec 395 / RM-181): the smoke probe hands a non-default engine a scratch HOME/XDG_* of its own instead of the operator's, so the usual ambient credential paths (~/.codex/auth.json, ~/.config/gh, ~/.gemini/) resolve inside a throwaway directory.

    What probeHomeIsolated: true means, precisely: Weftra created the scratch dirs, handed all five keys to the engine, the engine declared its authEnv key names (Engine.declaredAuthEnvKeys) and none of them is an overlay key (such a key would override the overlay — authEnv is applied last in buildSubprocessEnv), and no ENV: line the agent reported named a different value for any of them. Those are the checks in assessProbeIsolation (src/services/engine/smoke-test.ts); nothing else is claimed — in particular it is not a statement about paths a vendor binary hard-codes rather than reading from the environment. An engine that declares (declaredAuthEnvKeys === undefined marks every overlay key defeated in assessProbeIsolation); the profile-built engines always declare one.

    When it is false, the vendor process did not run under the scratch dirs, so the run says nothing about the profile authenticating on its declared authEnv alone — treat "vendor auth used" as unproven. The CLI reports this as a FAIL and exits non-zero even when the engine answered. If the scratch dirs cannot be created at all, the probe refuses to run rather than falling back to the operator's $HOME: ok: false, with the reason in errorMessage.

    probeHomeIsolated is 'n/a' for claude-code (its credential legitimately is host state by design).


Step 6 — Inspect the result ​

When the feature reaches implemented:

bash
curl http://localhost:3100/api/features/$FEATURE_ID | jq '{status, pr_url, branch_name}'
  • pr_url should point to a real draft PR on the test repo.
  • Clone the branch and verify hello.txt exists and contains VENDOR_VALIDATED.
  • Optionally merge the PR and verify Weftra transitions to merged.

Step 7 — Record the result ​

Document the test in your deployment notes:

Engine:     gemini-live (gemini-cli --acp)
Date:       <date>
Validator:  <name>
Feature:    <feature id>
PR:         <pr url>
Outcome:    PASS / FAIL
Notes:      <any issues encountered>

If the run failed, inspect the feature log for ACP protocol errors, permission denials, or vendor-side auth errors, fix the profile config, and repeat from Step 3.

Live-run validation log — append a row (per Step 7's template above) each time this playbook is completed against a real spec-conformant agent:

DateAgent + versionAuth mode(s)OutcomeNotes
(none recorded yet)

Spec 416 AC7 requires one row here — a governed-policy, write-kind-tool run showing the gate fire, the approver answer, and the tool proceed, validated under every auth mode the chosen agent supports — before that spec is treated as merge-ready. This is a manual, operator-run step; it is not satisfied by the automated test suite (§8 ACs 1-6 there prove the wire shape and policy dispatch against the mock, not a live agent).


4. Governance with non-Claude engines ​

ACP permission gating (specs 020/021) applies identically to all ACP engines. Set permission_policy=governed on the project to intercept write-kind tool calls mid-run regardless of which engine is running. The policy is enforced by Weftra's resolvePermission function before the response is sent to the agent — the vendor agent cannot bypass it.

See docs/USER_GUIDE.md §"ACP permission gating" for the full recipe.

4d. More than one account for a profile (Spec 457 inc-2d / RM-321) ​

A profile declared in FA_ENGINE_PROFILES with an authEnv can be given several accounts in the engine-account registry, so that runs on it spread across them. Each account is a host-variable suffix: for declared key K, the account reads host variable K_<SUFFIX>. Registration uses ref: "host-env-suffix:<SUFFIX>" with auth_mode: "api". The full recipe, the reserved-name rules and the health limitation (accounts on a non-Claude engine are balanced and recorded, but their health is not judged yet) are in docs/USER_GUIDE.md §"Accounts for another engine". Operations detail is in docs/OPERATIONS.md §34a.

Checking an engine account (Spec 457 inc-2d2a / RM-321) ​

An admin can ask Weftra to check any registered account — on its own engine — with POST /api/admin/engine-credentials/:id/check. Weftra runs its fixed check (the reply PONG) in its own container, which holds that one account and nothing else of yours. For an engine other than the default, the check connects only to the probeEgressHosts declared for that engine. An engine with none declared cannot be checked, and the answer says so. A PONG is recorded as "Confirmed working". Any other result is "Could not confirm", and writes nothing. A check of such an engine cannot determine that the account is blocked, and never marks it unhealthy. The USER_GUIDE §"Checking an account" describes the outcomes; the OPERATIONS §34a states what the container holds and does not hold.


4a. Honest metering and platform-wide budget caps (Spec 235) ​

Two governance properties are enforced by the platform, not by any one engine — so they hold identically whether a feature is routed to claude-code, acp, or a declared headless profile.

Cost: "unknown" vs. measured $0.00 ​

EngineMetrics.costUsd and ClaudeResult.costUsd are number | null:

  • null — the engine/transport did not measure cost for this run. ACP's session/prompt response and a declared headless command's stdout carry no cost figure, so both engines report costUsd: null. claude-code always reports a real, measured number (including a legitimate 0, e.g. under oauth auth where there is no per-token billing).
  • 0 — the engine genuinely measured zero cost. This is a real, tracked result — never confused with "unknown".

This distinction is load-bearing all the way to the operator-facing spend figures: a null run cost is never coerced to 0 and booked as a measured run. It does not inflate run_count, and it adds nothing to cumulative spend (getProjectCostSummary / getProjectReport in src/models/features.ts, and the Spend (cumulative) line in scheduled reports). A null-cost feature's dashboard row and provenance artifact render "unknown"/"—", never $0.0000.

If you write a new engine or headless profile whose transport doesn't surface a cost figure, emit costUsd: null — do not default to 0.

This applies to the cost metric only — not to token usage. inputTokens / outputTokens are reported by every transport (ACP's usage payload, the headless contract), and those numbers are real. They are accumulated independently of the cost guard, so a null-cost run's measured tokens still reach the per-project totals, getFleetRollup, and the scheduled digest's Tokens: XK in + YK out line. Withholding a dollar figure Weftra doesn't have is honesty; dropping consumption Weftra did measure would just be a second, quieter lie.

Budget cap: every engine enforces it, or refuses the task ​

EngineTask.maxBudgetTokens (a hard token ceiling, when declared) is enforced live by claude-code's stream loop. An engine that has no live enforcement point for it (ACP's single request/response exchange, a headless command's single stdin→stdout call) does not run the task uncapped — it refuses, failing fast inside its own run() with an error naming the engine, before any subprocess work happens. The refusal surfaces as an ordinary run failure (structured failure cause, spec 212) — the same path any other pre-flight rejection takes.

This is driven by a capability signal, not a hard-coded engine-id list: Engine declares a required enforcesBudgetCap: boolean. claude-code sets it true; AcpEngine and HeadlessEngine set it false and call the shared requireBudgetCapEnforcement() guard (src/services/engine/types.ts) at the top of run(). A future engine that gains real cap enforcement flips its own enforcesBudgetCap to true — no change needed anywhere else.

The binding does not depend on an engine remembering to call that guard. registerEngine() (src/services/engine/index.ts) is the single choke point every reachable engine passes through, and it applies the guard to any engine that does not declare enforcesBudgetCap: true — including an engine registered by out-of-tree code, and including one that omits the field entirely (undefined is treated as non-enforcing, the fail-safe direction). Engines keep their own in-run() call, so the refusal still originates inside the engine; the registry only makes it unskippable. Cap enforcement never moves into runEngine, which stays a pure passthrough.

Practical effect: a project with max_budget_tokens set must stay on claude-code (or a future engine that declares enforcesBudgetCap: true) if it wants that run to actually execute — routing it to acp/headless fails the run rather than silently dropping the cap.

The cap VALUE: the most restrictive declared level wins ​

Enforcing a cap is only a governance control if the party being governed cannot choose the number. max_budget_tokens is declared at three levels — per feature, per project, and instance-wide (FA_MAX_BUDGET_TOKENS) — and the first two are tenant-writable: a project key sets max_budget_tokens at POST /api/features and project.max_budget_tokens at PATCH /api/project, and the validator only requires a positive integer (there is no upper bound to validate against, because the ceiling is a different level's business).

So resolveBudgetTokens() (src/services/agent/budget.ts) takes the minimum of whatever levels are declared, not the first one it finds:

  • A feature or project may tighten its own spend below the level above it.
  • Neither can raise the ceiling: an operator who sets FA_MAX_BUDGET_TOKENS=250000, or a project cap of 100000, binds every run under it no matter what a tenant submits per feature.
  • A level that is absent (or a non-positive value that reached the DB by some path the validators don't cover) declares nothing and simply doesn't constrain. All three absent = uncapped, unchanged.

resolveBudgetSeconds() applies the identical rule to the wall-clock cap (max_budget_seconds / FA_MAX_BUDGET_SECONDS), which is tenant-writable through the same two routes.


Reviewer tasks load no CLI settings (claude-code only) ​

The security, conformance and diff-only designer reviewers send confineSettingSources: true. claude-code runs the CLI with --setting-sources '' (buildClaudeArgs, src/services/agent/claude-runner.ts), so the reviewed branch's CLAUDE.md / .claude/settings*.json and the mounted user settings do not load; credentials still do. acp and headless ignore the flag and do not refuse: an ACP agent may still read its own instruction files from the cloned tree.

Tool-less reviewer tasks: every engine enforces it, or refuses (Spec 549 inc-1) ​

The conformance and diff-only designer reviewers send toolless: true. claude-code enforces it with --tools '' + --strict-mcp-config (src/services/agent/claude-runner.ts); ACP forces a deny_all permission policy for that run (src/services/engine/acp-engine.ts; it cannot stop a tool the agent runs without asking); headless cannot enforce it and refuses the task (src/services/engine/headless-engine.ts, via requireToollessEnforcement in src/services/engine/types.ts). A reviewer role routed to a headless engine therefore fails its round visibly instead of running with tools; route it to claude-code or an ACP engine.

4b. Output-log visibility parity (spec 332 / RM-103 v2) ​

Every engine now writes its run to the same per-job workspace log (workspaces/logs/<jobId>.log) — claude-code has always done this; the ACP engine was previously blind (an ACP parse-fail left only the agent-init line, no output). AcpEngine.run() streams the session's text and tool-call events through the shared sink/formatter in src/services/agent/workspace-log.ts (the exact file claude-runner.ts also writes to), so a security/conformance round run under an ACP engine is now as debuggable as one run under claude-code.

Redacted by default. Every line funnels through redactSecrets before it touches disk — identical to claude-code's existing discipline (spec 132 §0.3). A secret an ACP agent echoes from repo/env content is redacted the same way a claude-code run's would be. Tool-call input/result lines are bounded to the same 200/300-char caps claude-runner.ts's formatStreamEvent applies — unconditionally, in every mode, log_full_output included (claude applies its caps with no escape, and the flag below changes redaction only, never bounds) — so an ACP log is never a richer, unbounded surface where claude's equivalent line is truncated. The model's own streamed text ([text] lines) is uncapped in both engines, so a parse-fail's verdict text is always fully visible.

Opt-in log_full_output (per engine profile), OFF by default. Set logFullOutput: true on an FA_ENGINE_PROFILES entry to skip the third-party shape-based redaction of that engine's workspace log lines, for deep debugging of output the shape patterns would otherwise mangle:

json
[{"id": "gemini-live", "cmd": "gemini-acp", "logFullOutput": true}]

When enabled, the log carries an explicit [warning] header stating the run's output may not be caught by shape-based redaction and may contain sensitive text. This does NOT disable masking of Weftra's own known configured secret literals (redactKnownSecretValues, src/utils/secrets.ts), this engine's own configured authEnv values, or Weftra's own deliberately-minted token shapes (the fa_ API-key, fatrg_ trigger-secret, famcp_ MCP OAuth-token, fa_helper_/fa_mcpgw_ run-token and fa_wet_/fa_wen_/fa_wtc_ worker-token patterns — redactKnownSecretValues applies all of them regardless of full) — only the third-party heuristic shape-pattern catch-all (GitHub/GitLab/Anthropic/Bearer) is what the flag opts out of (CLAUDE.md trust-boundary law #5: a per-engine debug flag cannot waive "secrets never appear in argv, logs, or provenance"). It is an admin/operator engine-config concern (declared alongside cmd/args/ authEnv), never a tenant/feature field.

Know the log's real audience before enabling it. The workspace log is not a host-filesystem-only artifact: GET /api/features/:id/logs serves the file verbatim — no read-time redaction — to admins and to every approver linked to that project. Enabling log_full_output therefore widens what that whole audience can read through the API, not just what lands on the operator's disk. The flag's warning header states this explicitly.

Engine-agnostic invariant, scoped to what THIS module writes. Neither workspace-log.ts nor acp-engine.ts writes anything to the run_events ledger or the audit export — only the redacted verdict/parse-fail status is recorded there, identical for claude-code and every ACP engine, exactly as before this feature. One separate, pre-existing mechanism touches the file: claim-stale-diagnosis.ts's readJobLogTailLive/jobLogPath copies the tail of the feature's author-run log (<featureId>.log only — prefixed role logs such as security-<fid>.log are not read by it) into a claim_stale_diagnosis run event when a claim goes stale, re-applying full redactSecrets (shape patterns included) to the tail first — so shape-known tokens written under log_full_output are still masked before any of those bytes can reach the ledger. That path is unchanged by this feature; its residual gap (the tail re-redaction has no per-run literal pool, same as every write path) is a pre-existing item tracked against that file, out of scope here.


4c. The sandboxed browser tool (Spec 350 / RM-120) ​

Weftra can build UI but, until this, could not SEE it — the reviewer only ever read a diff. The browser tool is a per-run, operator-granted capability that renders a declared app URL inside the sandbox and returns a screenshot + accessibility-tree DOM + console errors to the agent as multimodal input. It is advisory only: it sets no verdict and gates no merge.

How a run gets it. There is no settable "enable browser tool" field. A run is granted the tool purely because its resolved runtime_image is the operator-curated fa-runtime-browser:latest image — the same admin-only field that already selects any other runtime image (docs/OPERATIONS.md §4h-7). Setting runtime_image is already outside a project key's or an agent profile's reach, so the grant inherits that enforcement rather than adding a new one.

What Weftra writes, and what it doesn't. Weftra writes NO browser driver. The tool is the official @playwright/mcp server, run inside the fa-runtime-browser image (containers/fa-runtime- browser/Dockerfile — Chromium via Playwright, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright). Weftra's job is purely the TS wiring: select the image, register @playwright/mcp in the run's .fa-mcp.json routed through the spec-348 MCP gateway (never wired directly), scope allowed_tools to exactly that tool set plus Read, and bind egress to the declared app host(s). See docs/OPERATIONS.md §4h-11 for the full mechanism (the sandbox MCP-gateway transport, the runtime-wrap spawn, the egress union, the permission-gate composition).

Governance in one sentence. Reads (browser_snapshot, browser_take_screenshot, console/network inspection) auto-allow under the ACP permission gate; anything that acts on the page (browser_click, browser_type, browser_navigate, dialogs, …) is routed to the approver under a governed policy — the browser tool reuses the SAME ToolKind classification every other tool call goes through (governedDecision, src/services/engine/permission-gate.ts), adding no bespoke approval path.

Not a tenant-settable or profile-settable field. runtime_image is absent from SELF_CONFIGURABLE_PROJECT_FIELDS, present in TENANT_FORBIDDEN_FEATURE_FIELDS, and not a legal key in an agent profile's closed vocabulary (spec 285) — a project key or a profile can never turn this on for itself.

Consumers. Spec 286 inc-2 (spec 347, the rendered designer review) is the first role built on top of this primitive; any future UX-focused agent run can be granted it the same way.


4e. Any OpenAI-compatible model ​

An FA_ENGINE_PROFILES entry can point Weftra at any hosted endpoint that speaks the OpenAI chat-completions protocol, without a new engine integration. The profile declares an openaiCompatible block; Weftra drives one shipped, version-pinned ACP harness (OpenCode, opencode acp) that talks to that endpoint. This is host configuration only: the block is read from FA_ENGINE_PROFILES, never from a request, a project or a repository.

Profile shape ​

jsonc
{
  "id": "hosted-llm",
  "authEnv": { "HOSTED_LLM_KEY": "<the endpoint key>" },
  "openaiCompatible": {
    "baseUrl": "https://api.flint.example/v1",   // absolute https:// URL, DNS host name
    "model": "flint-large",                       // non-empty, at most 200 characters
    "apiKeyEnv": "HOSTED_LLM_KEY"                 // optional; must be a key of this profile's authEnv
  }
}

cmd and args are optional here and default to the shipped harness (opencode, ["acp"]); the profile must use the acp transport. A bad value stops startup with an error that names the failing field, for example FA_ENGINE_PROFILES[0] ('hosted-llm').openaiCompatible.baseUrl must use https:// — http:// is refused.

FieldRule
baseUrlAbsolute https:// URL. http:// is refused unless the host is listed in FA_EGRESS_PRIVATE_HOSTS (see "A model on your own machine"). No embedded credentials, query string or fragment. The host must be a DNS name, not an IP address, unless listed the same way.
modelNon-empty, at most 200 characters.
apiKeyEnvOptional. Names a key that exists in the profile's own authEnv. Omit it for an endpoint that takes no key.

Worked example: an operator running a hosted Flint endpoint sets

bash
FA_ENGINE_PROFILES='[{"id":"flint","authEnv":{"FLINT_API_KEY":"<key>"},"openaiCompatible":{"baseUrl":"https://api.flint.example/v1","model":"flint-large","apiKeyEnv":"FLINT_API_KEY"}}]'

restarts Weftra, and runs POST /api/admin/engines/flint/smoke-test. Nothing in Weftra's code names Flint or any other vendor; the endpoint, model and key variable are all operator-declared.

The egress rule ​

The baseUrl host is added to the run's egress allowlist for a run that uses this engine, and to the profile's probeEgressHosts (so the engine account check can reach it). No other host is added, and a run of any other engine never gets it. Weftra never widens an operator bound: when FA_RUNTIME_EGRESS=allowlist and the operator's list does not name the endpoint host, or the project is an approver-compiled worker deployment whose envelope does not, the host is withheld, a warning is logged, and the engine sees a refused connection until the bound names the host. A run in open egress mode is unchanged.

Key handling ​

Weftra translates the block into the harness's provider configuration and writes it as a file outside the workspace (the sandbox's read-only internal directory), so a repository never contains it. The key is not written to that file: the file refers to it as {env:HOSTED_LLM_KEY} and the harness reads it from its own process environment, which Weftra delivers by name only (docker run -e NAME). The key is therefore not in argv, the file, the run log or provenance, and every error text from the harness is redacted against the profile's authEnv values before it is logged or returned. The harness environment is built from nothing (never a copy of Weftra's own), and repository-level harness configuration is switched off so a committed opencode.json cannot repoint the base URL at another host.

Smoke test and account check ​

POST /api/admin/engines/:id/smoke-test for an openaiCompatible engine runs a one-prompt round trip through the harness, in the sandbox, with the endpoint host as the only allowed destination, and reports exactly one of confirmed_ok, auth_failed, unreachable or inconclusive in outcome. Only confirmed_ok sets ok: true; it needs the exact reply PONG. The harness reports provider errors without an HTTP status, so auth_failed and unreachable are recognised from the redacted message text, and an error that matches neither is inconclusive, never a guessed pass or a guessed failure. Without RUNTIME=docker the smoke test is inconclusive (it will not run the harness on the host). The engine account check (POST /api/admin/engine-credentials/:id/check) uses the same round trip and keeps its existing outcome names.

Metering and the budget cap ​

Cost is null unless the endpoint reports usage Weftra can price. The platform-wide FA_ACP_PRICING_MODEL estimate is not applied to this engine, because it prices a model this engine is not running. Token counts are recorded when the harness reports them. The harness has no live budget-cap enforcement point, so the spec 235 rule in §4a applies unchanged: a task that carries max_budget_tokens is refused by this engine rather than run uncapped.

A model on your own machine ​

A profile may name a model server on your own network (Ollama, llama.cpp and similar). This needs two settings, both operator-owned; neither alone does anything:

  1. openaiCompatible.baseUrl in FA_ENGINE_PROFILES, as http://<host>:<port>/v1, where <host> is an IPv4 address or a DNS name.
  2. The same host listed in FA_EGRESS_PRIVATE_HOSTS. Without it startup stops with an error naming both settings.
bash
FA_EGRESS_PRIVATE_HOSTS=172.17.0.1
FA_ENGINE_PROFILES='[{"id":"local-model","openaiCompatible":{"baseUrl":"http://172.17.0.1:11434/v1","model":"<model>"}}]'

apiKeyEnv stays optional: leave it out for a server that takes no key.

Never accepted, even when listed: localhost, any 127.x or 0.x address, 0.0.0.0, link-local (169.254.0.0/16, which holds the cloud metadata address) and any IPv6 literal. Inside the sandbox loopback is the container itself, so it can never reach a model on the host.

How it is reached. There is no direct route from the sandbox. The run's egress forwarder carries a bounded plain-HTTP forward for exactly the declared host and port, only for a run of this engine. Another port, another private host and link-local are refused, and the forwarder still resolves the name and pins the answer. Every decision is an audit line marked PLAIN (see OPERATIONS §4h-8f). HTTP_PROXY is set only for such a run.

Running the server.

bash
# Ollama: listen on the Docker bridge address, never on every interface
OLLAMA_HOST=172.17.0.1:11434 OLLAMA_CONTEXT_LENGTH=32768 ollama serve

# llama.cpp
llama-server --host 172.17.0.1 --port 8080 -m <model.gguf> -c 32768
  • Use a context of 32K tokens or more; an agent's prompts are large and a small context fails oddly.
  • Bind to the bridge address, never 0.0.0.0. 0.0.0.0 exposes the model to your whole network.
  • http:// is plaintext: the key (if any) and every prompt cross the bridge unencrypted. Prefer https:// to a DNS name where you can.
  • The model shares the machine with Weftra and the runs: expect resource contention, slower runs and out-of-memory kills under load.
  • host.docker.internal is not defined on Linux; use the bridge address (docker network inspect bridge).
  • The smoke test and account check work unchanged and report the same four outcomes. A refused or unreachable server is unreachable, never a pass.

What this does not do ​

It does not route any project to the engine by default or change role routing; select it the usual way (§5). It does not accept loopback, link-local or IPv6 endpoints, an http:// endpoint whose host the operator has not listed, or a value from a request, project or repository. It has no Anthropic-compatible shape.


Benchmark before routing work to it ​

The SWE-bench harness can run any declared profile with --engine-profile <id>, and its report names the profile and model and marks cost that the endpoint did not report. See "Running on a declared engine" in docs/swebench-provisional.md. The paired Pro run is operator-run.

5. Agent roles ​

Weftra defines eleven agent roles (a closed set, EngineRole): author, reviewer, fixer, security_fixer, security, analyze, answerer, revise, intent_gate, maintainer and designer.

engine_routing_policy accepts exactly those keys. The roles that consult it are author, reviewer, analyze and answerer, plus maintainer at project level (an engine id only, no classifier). The roles that never consult it are security, security_fixer, fixer, revise, intent_gate and designer; those resolve from their own admin-only static fields. Each value is a non-empty engine id or { "classify": true, "category"?: {...}, "complexity"?: {...} }, with category keys from small-patch | large-integration | bugfix | refactor | other and complexity keys from light | standard | deep. An unknown key or a malformed value is refused with 400 on every write route and nothing is stored. Engine ids are not checked against the registry at write time. Rows stored before this check are not rewritten and resolve as before.

The roles below can be routed to a different engine via engine_routing_policy (where they consult it) or per-role static fields:

The Tier-1 classifier predicts a complexity tier (light | standard | deep) alongside the task shape, and a role's classifier config may carry a complexity map from tier to engine id. It is consulted only after the category map misses, skips unregistered ids with a note, and fails closed to standard. The tier is recorded on routing_rationale.complexity and the routing_decision run event; operator-only roles never consult it, and a feature with an admin declared_category is not classified, so it has no tier.

RoleStatic field (project / feature)When it runsSemantics
authorengine / engineImplements the feature (the main coding agent).Transitions feature to implemented.
reviewerreviewer_engine / reviewer_engineSpec-conformance review after implemented.Posts a VCS APPROVE/REQUEST_CHANGES review. Terminal on reviewer_final.
security_fixersecurity_fixer_engine / security_fixer_engineOn-demand, admin-triggered targeted remediation of an open adversarial security finding (POST /api/features/:id/fix-security, Spec 136 inc-1), and the autonomous Security Fixer dispatch.Addresses exactly the findings, pushes to the same branch (no new PR). Bounded rounds + escalate-on-no-progress (security_fixer_final). Never auto-merges.
fixerfixer_engine / fixer_engineThe conformance Fixer rung reads it at a PR-sourced conformance cap or no-progress escalation, once per feature, and refuses on a model collision with the revise rounds.Admin-only. Set fixer_model to a model that differs from revise_model / revise_escalation_model; otherwise the reviewer parks as before. The Security Fixer does not read it.
securitysecurity_engine / security_engineAdversarial security review after implemented (Spec 120).ESCALATE-ONLY. Posts a PR comment; cannot approve or merge. Terminal on security_final. Re-runs after every revise.
reviserevise_engine / revise_engineAddresses PR review comments on an already-implemented feature (POST /api/features/:id/revise, Spec 191).Fetches the PR's review feedback, re-clones the branch, applies the changes, and pushes to the SAME branch (no new PR). Returns to implemented.

reviewer, reviewer_model, and reviewer_engine resolve via the standard feature→project→routing-policy→default chain (unlike security_fixer/security/revise below), but who may write them is admin-only (Spec 206 inc-1): a project key can never set reviewer, reviewer_model, or reviewer_engine on PATCH /api/project or POST /api/features (both reject with 400/403) — only PATCH /api/projects/:id or POST /api/features/admin (admin key) can. This closes a tenant→operator cost-escalation path: without it, a project key could turn on the reviewer and, via a partial-progress REQUEST_CHANGES loop, draw the operator's admin-configured escalation model at round-2. See docs/USER_GUIDE.md §"The spec-conformance reviewer" for the full lifecycle.

The security_fixer role resolves via an admin-only chain, like security — NOT the standard feature→project→routing-policy→default chain used by author/reviewer. Only the write-locked static fields (feature.security_fixer_engine, then project.security_fixer_engine), the admin agent profile for security_fixer, and the default apply; engine_routing_policy and the Tier-1 classifier are skipped even though they apply to other roles, because both are tenant-reachable (engine_routing_policy is self-configurable at the project level and unguarded on POST /api/features) while security_fixer_engine/security_fixer_model are not — consulting policy would let a tenant silently override the admin-set engine the moment /fix-security runs. The security_fixer_* fields are themselves admin-only settable (a project key can never set them, mirroring the security-role write lockdown). See docs/OPERATIONS.md §4d for the trigger, round cap, and gate-independence guarantee.

Migration note (the Security Fixer's role was renamed from fixer). The Security Fixer used to be configured through fixer_engine / fixer_model / fixer_escalation_model and the fixer agent-profile role. On first start after the upgrade, every non-null fixer_* value on a project or feature is copied onto its security_fixer_* twin and fixer_* is set to NULL, and a fixer key in a project's admin_agent_profile_roles is renamed security_fixer (if both keys already exist, both are left untouched and a warning is logged). It runs once per database; a value set on fixer_* afterwards is never moved. Resolution is unchanged by the move. The /fix-security per-run override keys are now security_fixer_engine / security_fixer_model; the old fixer_engine / fixer_model body keys are refused with 400.

The security role engine is resolved from admin-controlled config only — tenant-reachable fields (feature static fields, engine_routing_policy) are intentionally skipped so a tenant cannot route their own PR to a weaker model:

  1. project.security_engine (static field — admin key required to set)
  2. DEFAULT_ENGINE_ID (claude-code)

engine_routing_policy["security"] (at either project or feature level) is ignored for the security role. To route the security reviewer to a different backend than the implementer (recommended — uncorrelated-errors principle), use the admin API to set project.security_engine directly:

bash
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"security_reviewer": "adversarial", "security_engine": "gemini-profile", "security_model": "gemini-2.5-pro"}'

An unknown security_engine id is refused on write: the admin project create and update routes answer 400 (unknown engine '<id>'; valid: …) and store nothing. An engine profile that is pinned here and later removed from the registry is skipped, not run silently: the security role falls to the next step in its chain (the admin-declared agent profile, then DEFAULT_ENGINE_ID), and the skip is recorded in the routing record (routing_rationale.security.source and the routing_decision run event), so the record names the engine that actually ran. The reviewer-independence warning is computed from that engine. The same two rules hold for fixer_engine and designer_engine.

The revise role resolves via the admin-only chain — the same shape as fixer, NOT the standard feature→project→routing-policy→default chain used by author/reviewer/analyze/answerer. revise_model/revise_engine are settable only via PATCH /api/projects/:id or POST /api/features/admin (admin key); PATCH /api/project (self-service) and POST /api/features (project key) reject both with 400. Although POST /api/features/:id/revise is itself already project-key reachable, revise is the remediation agent for the tiered auto-merge loop — the same role fixer_model/ fixer_engine are locked down to protect — so a tenant must not be able to choose its own cost tier or credential set for its own remediation agent. Precedence when unset: feature revise_model/revise_engine → project default → the full author-role chain (so an admin's project.engine pin is inherited exactly as it was before Spec 191, including routing policy and the Tier-1 classifier at that layer) → config.agentModel / DEFAULT_ENGINE_ID. Unlike routing-policy/classifier values, the static revise_engine/project_revise_engine fields are validated against the registered engine list both at the resolver (an unknown id is skipped, not silently defaulted while being recorded as if it ran) and at the two admin write boundaries. See docs/USER_GUIDE.md §"Revise model and engine (Spec 191)" for the full recipe.

5a. Model escalation on retry (spec 205) ​

Two of the roles above are bounded remediation stages that can retry: revise (round 2, when the Conformance Reviewer's fix-list wasn't fully addressed) and fixer (now the security_fixer role — the Security Fixer, round 2+ of SECURITY_FIX_MAX_ROUNDS). Both may now be configured to escalate to a different model on the retry rather than re-running the model that just failed to converge — measured directly on PR #418 (2026-07-31): the Security Fixer's default model declined to act on round 1 (pushed=false, 0 files); round 2 on a stronger model produced the correct fix.

Two new admin-only fields, mirroring revise_model/security_fixer_model exactly (project default + feature override, PATCH /api/projects/:id / POST /api/features/admin only — a project key is rejected with 400 on every path):

FieldApplies toEffective from
revise_escalation_modelthe revise roleround 2 (the first retry)
security_fixer_escalation_modelthe security_fixer role (Security Fixer)round 2 (the first retry)

Unset = today's behavior, byte-identical. Round 1 (the first attempt) is never escalated regardless of configuration — only a genuine retry after a non-converging round uses the escalation model. There is no automatic model selection here (that's the Tier-1 classifier's job, §5 above) — this only decides whether to re-run the same model or switch, once a round is known to be a retry.

Trigger gating — a tenant can never drive escalation spend. The escalation fields are admin-only, but POST /api/features/:id/revise is project-key-reachable and Weftra-side uncapped, so the revise round counter alone must not select the escalation tier. Every revise trigger is attributed in the run-events ledger before the run starts (revise_requested {actor} from the two routes; reviewer_revise from the conformance reviewer's auto-revise loop), and the flow escalates only when the latest trigger is the reviewer loop or an admin — a project-key trigger, or a run with no recorded trigger at all, resolves the plain round-1 model no matter how high the round count is (fail closed).

Why a reviewer trigger is trustworthy — and it is not trustworthy by itself. The reviewer loop only mints reviewer_revise events for a project whose conformance reviewer is turned on, so trusting that trigger is only sound while a tenant cannot turn the reviewer on. Two further conditions therefore gate it, both operator-controlled:

  1. reviewer / reviewer_model / reviewer_engine must be operator-only. They are (Spec 206 inc-1, §5 above), and the flow re-checks it structurally at gate time against the live project-key-writable allowlists. If that lockdown ever regresses, a reviewer trigger stops being escalation-eligible instead of silently becoming tenant-drivable.
  2. The conformance reviewer must be live for the feature right now — resolved from those same admin-only fields, mirroring the security-reviewer-live guard on the Fixer queue. A stale reviewer_revise left on the ledger by a since-disabled reviewer cannot keep drawing the escalation model.

Without these, a leaked project key could flip its own project to spec_conformance, farm partial-progress REQUEST_CHANGES verdicts, and put every round-2 revise on the operator's most expensive model. Each revise_started records escalation_gate — admin, reviewer, reviewer_not_live, reviewer_arming_not_operator_only, untrusted_trigger, or no_trigger — so the ledger says which condition decided, for escalations and refusals. The round counter that selects the escalation tier is isolated the same way: each attempt's revise_started/revise_credit_paused rows are stamped escalation_eligible at origination, and the escalation round counts only stamped attempts. A tenant-triggered revise therefore neither runs the escalation model itself nor advances the round a later reviewer-/admin-triggered run reads — looping the project-key endpoint cannot flip the next trusted run's first attempt from the base model onto the escalation model. (The overall round in the payload still tallies every attempt, for audit; escalation_round records the isolated counter that did the selecting.) The Fixer half needs no such gate: /fix-security is admin-only and bounded by SECURITY_FIX_MAX_ROUNDS.

bash
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"revise_escalation_model": "a-stronger-model", "security_fixer_escalation_model": "a-stronger-model"}'

Independence corollary. The Security Reviewer (security_model) is deliberately run on a different model than the author for uncorrelated errors. If the resolved Fixer model (after escalation) ends up equal to security_model, the round still runs — this is a warning, not a block — but its security_fixer_started run-event carries independence_warning: true so the same correlation risk security_verdict already flags for the author is visible for the Fixer too. Pick an escalation model that differs from security_model to keep independence intact.

Auditability. Every escalated attempt is recorded in the run-events ledger with the model that actually ran, an escalated: true flag, and its trigger — revise_started for the revise role (mirrors security_fixer_started, which already carried model and now also carries escalated/independence_warning) and security_fixer_started for the fixer role. This is what a human merger reads to answer "which model produced this commit." revise_started is written at attempt origination, immediately before the engine runs (exactly like security_fixer_started), and the write is not best-effort: if the ledger insert fails, the attempt does not run — so an attempt that later pauses on credit exhaustion still has its audit row, and a ledger failure can never silently reset the round counter back onto the model that already failed. A credit pause writes a pairing revise_credit_paused event so the resumed call recomputes the same round (a resume continues the interrupted attempt — a paused first attempt resumes unescalated; a paused escalated attempt resumes escalated, never silently de-escalated).

No vendor model id is ever hardcoded in Weftra's source for this — the escalation model is pure per-project/feature declarative config, same as security_model.


6. Reliability: startup + idle deadlines (spec 149) ​

Weftra is unattended by construction, and AGENT_MAX_CONCURRENCY defaults to 1 — so one engine subprocess that is spawned but never makes progress (alive, but hung) can silently stall the whole engine loop forever. Both drive paths (AcpEngine and HeadlessEngine) bound every run with two independent deadlines instead of waiting indefinitely on a live-but-stuck child:

DeadlineEnv varDefaultWhat it boundsError prefix
StartupENGINE_STARTUP_TIMEOUT_MS90000 (90s)ACP only — the handshake (initialize + session/new) before any progress signal exists.engine_startup_timeout:
Idle / activityENGINE_IDLE_TIMEOUT_MS600000 (10 min)ACP: the session/prompt phase, reset by ANY parsed message (a session/update notification, a permission request, the final response). Headless: the whole run, reset by any stdout/stderr data from the child.engine_idle_timeout:

Setting either var to 0 disables that deadline (waits indefinitely again — pre-spec-149 behavior). Both are instance-level operator config; there is no per-project or per-feature override in this increment.

Why two clocks, not one. Startup has no in-flight progress signal to reset a clock against — a server that never replies to initialize looks identical to one that's about to reply in 5 more seconds, so it's bounded by a flat wall-clock deadline. The prompt/run phase can legitimately run for a long time (a large task, a slow model), so it must NOT be wall-clock bounded — it's bounded by inactivity instead: any observed progress resets the clock, so a run that keeps streaming updates never times out no matter how long it takes in total.

On breach: the engine runs its existing cleanup (ACP: best-effort session/cancel + SIGTERM; headless: SIGTERM) and throws an Error whose message starts with engine_startup_timeout: or engine_idle_timeout: and names the engine id (and, for a startup breach, which handshake phase hung). There is no new UI surface for this — the breach fails the run through the existing failure path (feature failed, run_events, dashboard/Active Runs), which already renders the error message; the classified prefix makes the cause legible there without a dedicated view.

env
# docs/SETUP.md "Production Deployment" block also lists these.
ENGINE_STARTUP_TIMEOUT_MS=90000   # 0 disables (ACP handshake only)
ENGINE_IDLE_TIMEOUT_MS=600000     # 0 disables (ACP prompt phase; whole run for headless)

See src/services/engine/deadlines.ts for the shared timer/error-classification primitives and tests/engine-transport-deadlines.test.ts for the full contract (mock servers that hang at each phase, a mock that proves periodic activity resets the idle clock, and env-configurability including 0 disabling).

7. Complexity-aware effort ​

When the routing classifier is enabled, each feature gets a predicted complexity tier (light, standard or deep). The operator can declare, per engine and per tier, how much effort the author's implementation turn gets, so simple tasks run cheap and hard tasks get the strongest model. It is declared in one host environment variable, FA_COMPLEXITY_EFFORT, and nothing a project, feature, agent profile or repository file says can set or change it.

Shape

json
{ "<engine-id>": { "<tier>": { "model": "...", "max_turns": 40, "max_budget_tokens": 2000000 } } }

Every key is optional. Anything else is refused at startup, with the engine, tier and key named: a non-object, an unregistered engine, a tier other than light | standard | deep, an unknown key, a model that does not match the model-name pattern, a non-positive or over-bound integer (max_turns at most 1000, max_budget_tokens at most 50,000,000), or a knob the engine does not honour.

Which engine honours which knob

Knobclaude-codeACP enginesHeadless engines
modelyesno (refused)no (refused)
max_turnsyesno (refused)no (refused)
max_budget_tokensyesonly if the engine enforces a budget capno (refused)

Precedence

  • No predicted tier (classifier off, or only a declared category) means nothing applies.
  • model: the tier model replaces the operator default model, but an author agent-profile model that applied wins (the resolver reports author_profile_model; the stored record says withheld, see below).
  • max_turns: when the project sets agent_max_turns, the result is the smaller of the two; otherwise the tier value replaces the instance default AGENT_MAX_TURNS and may exceed it.
  • max_budget_tokens: only lowers. The result is the smaller of the existing cap and the tier value, and a cap that is already lower is kept.

It applies to the author implementation turn only. The resolved knobs are stored on the feature's routing rationale under effort and written once as a complexity_effort_resolved run event before the turn. The run event is withheld from project keys, but the routing rationale is not: the project's own key can read routing_rationale.effort (feature detail and provenance), so it sees the tier, and the model, turn and budget values applied to its own feature. When the author agent profile declares a model, the recorded model knob is { applied: false, reason: "withheld" } whether or not that profile model was applied, so the rationale does not reveal whether the instance budget gate dropped it. The recorded max_budget_tokens knob follows the same rule: it is compared against the cap the feature or project set itself, never the instance default. If that cap is already at or below the tier value it records existing_cap_lower; otherwise it records { applied: false, reason: "withheld" }, because the real outcome depends on the instance default. The run still uses the real outcome.

Example

bash
export FA_COMPLEXITY_EFFORT='{"claude-code":{
  "light":{"model":"haiku","max_turns":25},
  "deep":{"model":"opus","max_turns":150,"max_budget_tokens":8000000}}}'

Released under the MIT License.