Skip to main content

ADR-0144: Risk-config activation four-eyes on the append-only audit trail (digest-bound, cadence-reconciled)

Date: 2026-07-26 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 4.8 (implementation agent)

Decision context:

  • Latency: negligible. The four-eyes handshake adds one extra risk_config_audit INSERT on a scrutiny-lowering activation and one indexed SELECT ... ORDER BY created_at DESC LIMIT 1 per checker action. No LLM, no network. Off the case-scoring hot path entirely (config activation is a rare super-admin operation).
  • Dependency surface: zero new packages, no migration, no new table, no new column. Reuses the existing append-only risk_config_audit trail, the ADR-0070 assert_distinct_approver primitive, and stdlib hashlib/json. One pure helper (_config_digest).
  • Debuggability: every state transition (activation_pendingactivation_approved / activation_rejected / activated) is an immutable audit row naming the actor, the reason, the rationale, and (on the pending row) the digest of the exact calibration under review. "Who requested what, who approved/declined it, and was it the same calibration?" is answerable from the trail alone (EU AI Act Art. 12).
  • Reversibility: additive. The gate only withholds a scrutiny-lowering activation pending a second approver; a scrutiny-raising / scrutiny-neutral activation is byte-identical to the pre-#626 immediate path. Reverting is deleting the gate branch in activate_version + the two *_second_approval functions — no data change.
  • Blast radius: confined to risk_config_service activation. The persisted config format is unchanged; consumers of get_active_config are untouched.
  • Alternative considered: a dedicated mutable pending_activation column/table (the ADR-0070 shape). Rejected here — see Divergence from ADR-0070 below.

Context

Issues #626 (audited risk-appetite editor) and #625 (AMLA monitoring-calibration editor) added the first mutation surfaces over the audited, versioned risk_configurations.config_data (ADR-0092). Activating a draft that lowers due-diligence scrutiny — a looser risk_appetite / tier_thresholds ceiling (#626), a longer review cadence, or a widened expected-activity deviation band (#625) — is a safety-relevant action. Per the never-suppress doctrine (ADR-0067) and the maker-checker control (ADR-0070), a scrutiny-lowering change must be authorised by a second, different approver; a scrutiny-raising or neutral change stays immediate (fail-toward-scrutiny).

This introduced a new four-eyes protocol on a new surface, and the shipped design derives the pending-approval state from the append-only risk_config_audit trail rather than from a dedicated mutable pending-state record. ADR-0070 deliberately chose a dedicated pending state (the PENDING_SECOND_APPROVAL case status) and noted that resolution requires mutable state. Codex correctly flagged (post-merge review of #692/#693) that this divergence had not been recorded in an ADR and that its storage and concurrency invariants were undocumented. This ADR is that record.

Two further correctness properties were flagged and are documented here because they are load-bearing to the design's soundness, not incidental:

  1. The second approval must bind to the exact calibration that triggered the gate. The draft stays editable while a request is pending. Without binding, a maker could request approval for calibration A (which trips the gate) and silently swap the draft to calibration B before the checker signs — the checker would authorise a calibration they never reviewed.
  2. A tightened cadence must reconcile already-persisted review due dates. The monitoring review cadence (#625) is read fail-closed at compute time, but existing EntityBaseline.next_review_due values were written under the previous cadence. upsert_baseline is the only path that rewrites them, so an entity with no fresh investigation would sit parked on a stale, looser deadline while the framework record reports the new, tighter cadence as effective.

Decision

1. Pending four-eyes state is DERIVED from the append-only risk_config_audit trail

A scrutiny-lowering activation (activate_version, when activation_lowers_scrutiny(current_active, draft) is True) does not flip the draft to active. Instead it appends an activation_pending audit row and returns a {"status": "pending_second_approval", ...} marker. A second, different super-admin resolves it by appending an activation_approved (→ the draft is promoted) or activation_rejected (→ the draft stays a draft) row.

get_pending_activation derives openness purely from the trail: a request is open iff the most recent activation-lifecycle row for the config (activation_pending / activation_approved / activation_rejected / activated) is an activation_pending. There is no mutable pending-state column.

Storage invariants:

  • The draft's risk_configurations.status stays 'draft' for the entire handshake. The authoritative effect (draft → active) is a single atomic status flip performed only by _flip_draft_to_active, shared by the immediate and the four-eyes paths.
  • The audit trail is append-only and immutable (ADR-0064/0109) — a resolution never mutates the pending row; it appends a resolving row that supersedes it in the derivation.
  • The pending row's diff_data carries {reason, maker_user_id, config_digest}.

2. Concurrency invariants (why append-only derivation is sound here)

  • No forked activation. The authoritative state change is the single UPDATE ... SET status='active' WHERE id=:config_id AND status-guarded flip inside _flip_draft_to_active. approve_activation_second_approval re-reads the target and refuses unless it is still a draft ("no longer a draft; the pending activation is stale"). Two concurrent checkers therefore cannot both promote: the first flips draft→active and appends the resolving row; the second finds either no open pending request (get_pending_activation now returns None) or a non-draft target, and raises. At most one promotion occurs.
  • Deterministic ordering under a shared timestamp. Within a single transaction now() is constant, so a request and its resolution can share created_at. The ORDER BY created_at DESC, (action='activation_pending') ASC tiebreaker makes the resolving row sort first (Postgres false < true), so the request always reads as closed once resolved — never a phantom-open request.
  • Segregation of duties, fail-closed. Both *_second_approval functions call the ADR-0070 assert_distinct_approver(maker, checker) before any state change; a same-actor or blank-actor approval raises (SelfApprovalError), leaving the draft pending.
  • Fail-closed comparison. If activation_lowers_scrutiny cannot resolve a comparison it returns True (route to four-eyes) rather than waving the activation through. The first-ever activation (no current active config) is not gated (there is no prior scrutiny level to lower).

3. The second approval is bound to the reviewed calibration by an immutable digest

_config_digest(config_data) = SHA-256 over the config's canonical JSON (sort_keys=True, compact separators) — dict-ordering / whitespace independent, so an unchanged config always digests identically and any edit (looser cadence, widened band, different appetite) changes it. The digest of the draft that tripped the gate is recorded on the activation_pending row. approve_activation_second_approval re-computes the digest of the current draft and refuses to activate unless it matches the recorded digest. A pending row with no recorded digest (a legacy row predating this binding) is likewise refused — re-request rather than approve blind. The checker can only authorise the exact calibration that triggered the four-eyes gate (fail-closed, never-suppress).

4. A tightened cadence reconciles persisted review due dates at sweep time

sweep_due_reviews (periodic review, ADR-0083) now computes an effective review due date per baseline: _effective_review_due returns the earlier (min) of the persisted next_review_due and the date recomputed from the tenant's current effective cadence (last_investigated_at + effective_cadence[tier]). The min guarantees a tightened cadence can only pull a review forward, never push it later (fail-toward-scrutiny, ADR-0067); an already-overdue persisted date is always honoured. A coarse SQL pre-filter (next_review_due < now OR last_investigated_at < now - min_cadence) over-fetches candidates; the precise per-tier due test runs in Python. So an entity with no fresh investigation is no longer parked on a stale, looser deadline after the cadence is tightened, and due_count stays honest. upsert_baseline remains the only path that rewrites the persisted date; the sweep only reads it more strictly.

Divergence from ADR-0070 (and why it is acceptable here)

ADR-0070's case maker-checker uses a dedicated mutable PENDING_SECOND_APPROVAL case status because a case is a long-lived, frequently-queried aggregate whose current disposition is read on nearly every surface (dashboard filters, SLA clocks, decision gates); deriving it from an audit scan on every read would be costly and error-prone.

A risk config draft is different:

  • Its authoritative lifecycle state already lives in a mutable column (risk_configurations.status ∈ draft/active/archived); the four-eyes handshake does not need a second mutable state machine — the draft simply stays 'draft' until promoted, and "is there an open request?" is a rare, super-admin-only read.
  • The activation trail must be immutable and complete anyway (EU AI Act Art. 12 / AMLR audit), so the pending/approved/rejected events must be appended regardless. Deriving openness from the same trail keeps a single source of truth rather than a mutable pending record that could drift from the immutable evidence of what happened.
  • The concurrency risk that a mutable pending record guards against (a forked promotion) is already closed structurally by the status='draft' guard on the single atomic flip — the derivation is not what protects the invariant.

The divergence is therefore scoped and safe: no mutable pending-state record, the authoritative flip stays single and guarded, and the immutable trail is the one source of truth for the handshake.

Consequences

  • One new ADR; no code beyond #692/#693 (this ADR documents the shipped design and records the two hardening fixes — digest binding and cadence reconciliation — added in remediation of the post-merge review).
  • The risk_config_audit action vocabulary now includes activation_pending, activation_approved, activation_rejected alongside the existing created/updated/activated/archived.
  • Second-party scrutiny: this ADR + the testcontainer coverage in test_risk_config_service_db.py::TestActivationFourEyes (including the digest-binding refusal) and the sweep reconciliation coverage constitute the recorded design review.

Alternatives considered

  • Dedicated pending_activation table/column (the ADR-0070 shape). Rejected: it would double-represent state that is already fully and immutably captured in the audit trail, introducing a drift risk, without closing any invariant the status='draft' guard does not already close.
  • Lock the draft (make it read-only) while a request is pending instead of digest-binding. Rejected as the primary control: a maker legitimately iterating on a calibration should be able to edit and re-request; the digest binding lets the draft stay editable while guaranteeing the checker only ever authorises the reviewed bytes. (Locking could be added later as UX; it is not required for correctness.)