Skip to content

ADR-001: Warden owns the suite case, and every risky run passes a frozen approval

Source of truth: warden/docs/adr/001-warden-case-authority-and-approval-model.md (synced at build time — edit the original, not this copy). Status: Accepted (2026-07-27; Decision 4 implemented 2026-07-29, Decision 6 implemented 2026-08-06, Decision 5 implemented 2026-08-09). Product-local ADR; records Warden’s case-authority, run-approval, and cross-product attribution model. Suite identity and roles are owned by veltrosecurity/veltro ADR-008. ADR-014 supersedes the old standalone/local-auth installation model; the case-authority and approval decisions in this ADR remain in force.

Three products write to one incident narrative. CHAD detects and can comment on its own alerts; VectorFlow moves the logs; Warden runs the response. Without a single owner of “what happened and how it was closed”, the same incident acquires two lifecycles that drift — and a disposition recorded in one place silently contradicts the other. CHAD still ships a local Cases API/UI, which is exactly that drift.

Response automation adds a second problem. A runbook step can disable an identity, push an edge blocklist entry, or write an exception back into CHAD’s detection content. Approving such a step is only meaningful if what executes is byte-identical to what was reviewed: if the runbook YAML, the resolved connection, or the rendered input can change between approval and execution, the approval authorizes nothing.

Warden is served behind the Veltro gateway. Browser assertions and exchange-issued actor envelopes both reach its authorization checks, so the same case scope and approval policy must hold for analysts and MCP actors.

Decision 1 — Warden is the sole suite case authority (implemented)

Section titled “Decision 1 — Warden is the sole suite case authority (implemented)”

Cases, case status, disposition, and the single append-only case timeline exist only in Warden. Other products link to a case; they do not keep one. CHAD’s remaining local Cases surface is drift to be removed (veltro issue #135), not a second authority to reconcile with.

Decision 2 — Forward-only case status, disposition required to close (implemented)

Section titled “Decision 2 — Forward-only case status, disposition required to close (implemented)”

backend/app/services/case_service.py:change_status is the single writer. _STATUS_TRANSITIONS contains no backwards edge. closed is reachable only from resolved, or from new as a fast-dismiss restricted to the false_positive/duplicate dispositions (_FAST_DISMISS_FROM, _FAST_DISMISS_DISPOSITIONS). Closing without a valid disposition raises, mapped to 422 at the API layer. Runbook automation calls the same function — there is no automation bypass. Covered by backend/tests/test_cases_api.py.

Decision 3 — Runs execute an immutable version; approvals freeze intent and resume on digest (implemented)

Section titled “Decision 3 — Runs execute an immutable version; approvals freeze intent and resume on digest (implemented)”

A RunbookRun pins runbook_version; the worker executes that frozen RunbookVersion, never the live YAML on disk or in Git (backend/app/services/runbook_engine.py:run).

When a step declares approval:, or when the default-deny risk gate trips, runbook_engine.py:_Executor._open_gate freezes the reviewed intent onto the Approval row: action_name, the validated rendered_input, a masked preview that includes the resolved connection target, and an input_digest. On resume the digest is recomputed and compared; a mismatch (or an incomplete/legacy frozen binding) expires the approval and reopens a freshly frozen gate rather than executing on trust. Expired approvals fail the run.

The risk gate itself applies to automatic-origin (alert- and schedule-triggered) runs: _Executor._blocking_risk reads the action’s response_contract.blast_radius and danger flag, not the coarse legacy risk string. Bounded/low, non-dangerous actions — including case.update on the run’s own bound case — are AUTONOMOUS by default (runbook silence does not mean “ask”), while high/unbounded invocations (ssh.command, http.request against a caller-supplied absolute URL) and any danger action stay default-deny behind a synthesized Approval unless the step declares autonomous:. http.request pinned to a configured connection is bounded by that connection. The rule that allowed each unapproved step is recorded on the run (RunbookRun.autonomy) and in the step’s hash-chained action-log output. (veltro #394)

Approval authorization is team-scoped: backend/app/api/runs.py:approve_run requires the approve_runbooks permission, _require_team_approval re-evaluates the caller’s effective role on the run’s owning team (not the collapsed User.role), and the decision must fall inside Approval.required_role, with admin exempt.

Decision 4 — Four-eyes is an environment/team policy, secure by default (implemented)

Section titled “Decision 4 — Four-eyes is an environment/team policy, secure by default (implemented)”

Requester-distinct decisions are enforced for both approval and denial. A user whose id matches RunbookRun.requested_by is denied even when that user is an administrator, unless identity holds an active four-eyes exception for the run’s immutable deployment environment and, optionally, its owning team (veltro ADR-016, veltro#1158). Realm admins grant and revoke exceptions in identity’s Settings > Approvals; every exception has a non-empty reason, grantor, creation time, expiry, and optional revocation evidence. Team-scoped exceptions are more specific than environment-wide exceptions. Runs with no environment or no owning team never ask identity and fail closed to four-eyes. When identity can’t answer at decision time, a requester’s own decision is refused (503, recorded on the run’s action chain, the approval stays pending) and anyone else decides under plain four-eyes. Warden’s own approval_policy_exceptions rows were imported into identity once by the worker and are no longer read.

WARDEN_ENVIRONMENT is copied onto every run at creation so a later deployment configuration change cannot widen an already-pending gate. The shared evaluator in backend/app/services/approval_policy.py is called at decision time, so pending approvals are never grandfathered across expiry or revocation. Normal RBAC and team-effective-role checks run before this policy and remain mandatory; an exception relaxes requester distinction only.

Every accepted decision stores an immutable Approval.decision_policy snapshot with the evaluated scope and complete matched-exception evidence. The action-log entry and case timeline carry the same snapshot. The worker validates the snapshot, decision actor, requester distinction, run scope, and exception timestamps before execution; legacy, incomplete, or malformed evidence reopens the gate rather than executing. Rejected self-decision attempts are recorded in the run action hash chain, while exception grants/revocations are recorded in the global audit hash chain. Browser, API, and MCP actors submit through the same decision route and therefore the same evaluator.

Decision 5 — Cross-product writes carry the responsible analyst (implemented)

Section titled “Decision 5 — Cross-product writes carry the responsible analyst (implemented)”

Approved intent: every cross-product mutation preserves the initiating human analyst as first-class attribution, and the receiving product retains both the service and the human actor in its own audit trail (veltro issue #141).

Warden freezes the authenticated run initiator on manually started runs and the validated decision actor inside approval-policy evidence. Every CHAD mutation action requires one of those snapshots, revalidates that the persisted user remains active and authorized before egress, and mints a product-scoped CHAD write token (chad:alerts:write, chad:rules:write, chad:rules:deploy) carrying the complete v2 actor tuple: the actor’s email, the coarse suite role from the published NATIVE_TO_COARSE["warden"] map, and the CHAD-native role that coarse role names. The unbound external:* family has no product binding for the tuple to validate against, so it is never used for a human write (veltro ADR-008 Amendment 6, #362). The action and audit hash chains retain the human id/email/role, Warden’s transport identity, CHAD operation, request id, and token jti, but never token material. The automatic case-reference outbox remains an explicitly actorless machine lane.

Decision 6 — Acknowledgement is an explicit analyst action, and the ack clock stops there (implemented)

Section titled “Decision 6 — Acknowledgement is an explicit analyst action, and the ack clock stops there (implemented)”

Taking a case is a lifecycle transition of its own, not a side effect of another one. backend/app/services/case_service.py:acknowledge — reached only through POST /api/cases/{id}/acknowledge (manage_cases) — is the single writer of Case.acknowledged_at and Case.acknowledged_by. It stamps them exactly once, appends an acknowledged timeline event plus a case.acknowledge audit-chain entry, and is idempotent: a repeat call keeps the FIRST analyst’s stamp and writes no second event, because an acknowledgement SLA is about who arrived first.

Acknowledgement is deliberately not conflated with the facts next to it. It assigns no owner and moves no status; assignment, reassignment, resolution and closure never acknowledge. Automatic case creation is not acknowledgement: webhook intake, alert escalation and template creation all leave a case unacknowledged, and no policy currently opts an automatic origin in — if one is ever wanted it must say so explicitly rather than be inferred from the fact that a case exists. A never-acknowledged CLOSED case cannot be acknowledged after the fact (422); back-dating an outcome onto a finished case would corrupt both its ack SLA and MTTA.

The deadline itself stays where it already lived: the ack_minutes rule of the case’s SLAPolicy, applied at creation by sla_service.apply_sla_on_create. What changes is that the clock now stops. sla_service.build_ack_sla projects the state (none/pending/met/breached) from persisted columns only, freezing at acknowledgement — or, for a case that leaves OPEN_CASE_STATUSES without one, where it stopped being open, which is exactly the set sla_service.evaluate accrues. An on-time acknowledgement can therefore never breach later, a late one stays breached, and both stay explainable from due_at versus clock_stopped_at. Covered by backend/tests/test_case_acknowledgement.py and backend/tests/test_case_acknowledgement_api.py.

Positive. One case lifecycle for the suite, with a disposition that cannot be skipped. An approval that provably authorizes a specific execution rather than a step name. Requester-distinct decisions with explicit, narrow, expiring solo-mode exceptions and immutable policy evidence. A safe default for unattended runs, so a new action that forgets to declare its risk gates rather than fires. An acknowledgement SLA that can be satisfied and not only breached, because taking a case is now a recorded transition instead of an unwritten column.

Costs and accepted risks.

  • Human-authorized CHAD writes fail closed if their persisted initiator or approval actor is missing, malformed, inactive, or no longer holds the frozen role.
  • Forward-only status means a mis-set status is corrected by moving forward and re-recording, never by rewinding; the timeline keeps the mistake. This is intentional.
  • Digest-verified resume means editing a runbook while a run waits invalidates the pending approval — the approver is asked again. Also intentional.
  • runbooks/bruteforce-response.yml is kept and must be completed into a real approval-gated, reversible response (veltro issue #139); it currently ships with no approval: step and no reversal path, leaning on the default-deny risk gate as a backstop. It is not to be renamed or downgraded to triage-only.
  • Run retention now bounds runbook_runs (veltro issue #136): the worker offers a leader-safe, durably scheduled, bounded sweep (backend/app/services/retention.py:run_due_sweep). It deliberately retains a run whose approval is still undecided, whose response enforcement is unresolved, or whose case is still open, so bounding history can never cost a decision still owed or in-force containment evidence. action_log rows outlive their run with a null run link, so the hash chain stays complete and verifiable.
  • Suite-level session properties this ADR depends on are not yet where they need to be: grant revocation is not effective within 60s (veltro issue #129) and the signing domain is still shared-symmetric (veltro issue #130). Both are ADR-008’s targets, not Warden-local decisions.

See ../../CONTEXT.md for the full implemented-vs-target table.