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 byveltrosecurity/veltroADR-008. ADR-014 supersedes the old standalone/local-auth installation model; the case-authority and approval decisions in this ADR remain in force.
Context
Section titled “Context”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.
Consequences
Section titled “Consequences”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.ymlis kept and must be completed into a real approval-gated, reversible response (veltro issue #139); it currently ships with noapproval: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_logrows 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.