On this page▾
Self-Hosted OverviewSecurity coverageData sourcesWhat the kill switch doesFailure modesMCP conformanceRequirementsQuickstart (Helm)Kustomize / kubectlStorage & PVCsAWS EKSGCP GKEAzure AKSOn-Prem / Bare MetalSecrets & Env VarsIngress & TLSCustomer dashboard imageAir-Gapped ClustersVerify InstallUpgradingBackup & RestoreTroubleshooting
Failure modes
What AgentWatch does when one of its own dependencies fails, at every
decision point. Each "Tested" row names the fault-injection test in
backend/tests/test_failure_modes.py that proves it; each test is mutation-
checked by scripts/guard_sweep.py (break the behaviour, the test goes red).
Rows marked Gap are behaviour we have not built or not proved; they are
listed so nobody has to discover them.
Three rules
- Telemetry failure never blocks the agent. A dead collector, Redis or
Socket.IO does not fail
POST /steps. - A policy check that fails follows the passport's
enforce_mode.fail_closed(the default) on an enforcing passport denies.fail_open, or a passport indry_run, allows, and the allow is recorded as apolicy_check_failed_opensecurity event (Gate also writes agateway_verdictsrow with reasonfailopen_internal_error). An allow caused by a failure is never silent. - One bad detector degrades to "no finding", never a 500.
denied_tools is evaluated before any of this and is enforced by design, in
every mode.
Matrix
| Decision point | Dependency failure | Behaviour | Proof |
|---|---|---|---|
POST /steps/preflight |
passport tool check raises | fail_closed+enforce: deny, reason internal_error:passport_check. fail_open: allow, reason failopen_internal_error:passport_check, security event |
test_preflight_check_error_fail_closed_denies, test_preflight_check_error_fail_open_allows_and_records |
POST /steps/preflight |
egress check raises | same, stage egress_check |
same tests, egress_check case |
POST /steps/preflight |
any check raises, passport in dry_run |
allow + security event (dry_run never denies) | test_preflight_check_error_under_dry_run_allows_and_records |
Gate (/gw/*) |
egress check raises | fail_closed: JSON-RPC error egress_check_error. fail_open: call forwarded, security event (surface gate) |
test_gate_egress_error_fail_closed_denies, test_gate_egress_error_fail_open_allows_and_records |
Gate (/gw/*) |
any preflight check raises | fail_closed: deny, internal_error. fail_open: allow, failopen_internal_error. Both persisted to gateway_verdicts |
test_gate_internal_error_follows_enforce_mode_and_is_persisted |
Gate (/gw/*) |
upstream MCP server down / 5xx | JSON-RPC error to the agent; the API does not crash | test_gate_upstream_down_is_an_error_not_a_crash |
POST /steps |
a detector raises | that detector yields nothing; the others still run (an injection in the same step is still flagged) | test_ingest_survives_a_raising_detector |
POST /steps |
security.scan_step raises |
step stored, received: true |
test_ingest_survives_a_security_scan_failure |
POST /steps |
a guardrail rule raises | other rules still evaluated. Errored enforce rule on a fail_closed agent: run paused for a human. Otherwise the step continues and a security event (stage guardrails) is recorded. Errored dry_run rules never pause |
test_guardrail_error_on_fail_closed_agent_pauses_the_run, test_guardrail_error_on_fail_open_agent_continues_and_records, test_guardrail_error_on_a_dry_run_rule_never_pauses |
POST /steps |
Redis down (job queue) | step stored, request succeeds; the async task for that step (semantic detectors, injection classifier, confidence score) is dropped and counted in agentwatch_task_enqueue_failures_total{task=…} |
test_ingest_survives_the_job_queue_being_down |
POST /steps |
Socket.IO / Redis pub-sub down | live-view broadcast is skipped; ingest unaffected. At boot, an unreachable Redis falls back to the in-process manager | test_realtime_emit_failure_is_swallowed |
| OTel export | collector unreachable | BatchSpanProcessor (off the request path); export_run returns in under 0.5 s with the collector dead |
test_otel_export_is_batched_and_does_not_block_on_a_dead_collector |
| Semantic detectors (worker) | embedder times out | task finishes without a finding; no crash | test_semantic_task_survives_an_embedder_timeout |
| SDK telemetry | AgentWatch API unreachable | agent keeps running; steps are dropped | test_sdk_telemetry_fails_open_when_the_api_is_unreachable |
SDK preflight, fail_closed=True |
AgentWatch API unreachable (at run start or at a check) | AgentWatchBlocked, reason preflight_unavailable. Before G3 an unreachable API at run start made the run unsampled and every later check returned allow |
test_sdk_fail_closed_preflight_blocks_when_the_api_is_unreachable |
| SDK preflight, default | AgentWatch API unreachable | allow, reason preflight_unavailable (fail-open is the SDK default; preflight is cooperative) |
SDK default, sdk/agentwatch/run.py |
| API worker (re)start | traffic already flowing | Boot migrations skip already-applied DDL and serialise on an advisory lock, so a restarting worker takes no table lock that live requests wait on | test_a_worker_booting_under_load_takes_no_table_lock (test_migration_cost.py) |
POST /steps (many concurrent writers, one tenant) |
chained audit rows | The audit-chain head is locked only at commit, so a tenant's requests no longer queue behind each other's whole transaction | test_head_lock_is_not_held_between_flush_and_commit (test_audit_chain.py) |
| Any endpoint | DB connection pool saturated | Requests queue for a connection (up to 30 s) instead of the worker stalling. The pool (10 + 20 per worker) outsizes the 20-thread request pool, the browse-only check no longer queries on the event loop, and Gate no longer holds a connection across its upstream call | test_pool_outsizes_the_threadpool, test_browse_only_check_does_not_block_the_event_loop, test_gate_releases_its_db_connection_during_the_upstream_call |
| Any endpoint | Postgres down | request fails (5xx). SDK telemetry fails open; SDK fail_closed preflight denies (rows above) |
Gap: no server-side test with Postgres stopped |
| Any endpoint | Postgres slow | no server-side statement_timeout on the ingest or preflight path; a request waits until the SDK's 10 s client timeout, then the SDK row above applies. Only /nl/query has a 5 s timeout |
Gap |
| Any endpoint | disk full on the DB host | Postgres PANICs on WAL fsync and restarts; writes fail until space is freed | Gap: observed in development during G2, not handled |
What changed in G3
Building this matrix, and the G4 load test after it, found these places where a failure was a silent allow or an outage:
/steps/preflightswallowed exceptions from the passport and egress checks, so the check passed silently. Now it followsenforce_modeand records it.- Gate's
fail_openegress-check path allowed without a record. It now writes a security event. - An unreachable Redis at enqueue time turned
POST /stepsinto a 500 after the step was committed. The SDK then retried and double-logged it, or dropped it. Now the task is dropped and counted. - One guardrail rule raising failed the whole ingest. The SDK then treats that
as "unreachable" and carries on, so a broken rule was a silent bypass. Now
rules fail independently, under the agent's
enforce_mode. - The SDK's
fail_closeddidn't cover run start (see the SDK row). - Above about 15 concurrent callers per worker, every request stalled for 30 s. A query on the event loop waited for a pooled connection held by request threads, which needed the loop to give it back. Gate also held a connection across each upstream LLM/MCP call. Measured: preflight at 50 concurrent callers fell to 2.9 requests/s, all timing out.
- A worker restarting under load deadlocked live ingest (500s) with its boot migrations. A tenant's concurrent ingests also serialised on the audit-chain head lock, held for the whole request. Both are fixed; see the two rows above.