Skip to main content

ADR-0115: Agent Lifecycle in the Immutable Audit Trail + Merged Audit View (#365)

Date: 2026-07-17 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Fable 5) — epic #350 wave 2, extending ADR-0064 (audit immutability) and ADR-0109 (hash chain) usage to the agent lifecycle; consumes ADR-0026/#369 prompt provenance

Decision context:

  • Latency: one additional AuditService.log_event per terminal agent transition (~27 per full investigation run) — each an advisory-lock-serialised single-row append (ADR-0109), off the officer's synchronous path (runs inside pipeline activities). GET /cases/{id}/audit gains one always-run DB SELECT it previously skipped when Temporal answered; both sources were already indexed per-case.
  • Dependency surface: zero new packages. Reuses AuditService.log_event (the ONE hash-chained writer), agent_progress_service.update_status (already the single terminal-transition choke point), and the existing ["auditLog", workflowId] react-query key. Owned lines: one best-effort audit block, one pure merge function, one summary component.
  • Debuggability: every agent completion/failure becomes an immutable, per-tenant hash-chained agent_completed/agent_failed row carrying agent_name, iteration, model, findings_count, duration_ms, prompt_version_id (and error_message on failure) — joinable to agent_executions and to the prompt registry. A failed audit write is LOUD (logger.warning with traceback), never silent.
  • Reversibility: fully reversible in minutes — delete the best-effort block and restore the early-return in get_audit_log; no migration, no schema change, no workflow touch. Existing rows remain valid audit history.
  • Blast radius: additive. The audit endpoint's response shape is unchanged ({events: [...]}); it now returns a superset (union) of what it returned before. The pipeline cannot be blocked by the new write (best-effort by contract). The case-page change is confined to the collapsed-panel summary line.
  • Alternative considered: dual-write from every agent call site — rejected; update_status is already the single terminal choke point, and per-agent writers would drift (the exact unreconciled-paths failure mode of the case-pack audit).

Context

The OSINT pipeline records a rich per-agent lifecycle: ~27 agent_executions rows per investigation run (status, model, findings_count, duration_ms, and — since #369 — prompt_version_id). None of it reached the tamper-evident audit trail. agent_executions is a mutable operational table (upserted in place, no immutability trigger, no hash chain); the compliance story — EU AI Act Art. 12 logging, AMLR 5-year retention, ADR-0064 immutability, ADR-0109 tamper-evidence — hangs off audit_events, which never heard that an agent ran, succeeded, or failed.

Two adjacent honesty defects compounded this (verified live):

  1. Either/or audit view. GET /cases/{id}/audit returned the Temporal in-memory workflow events or the DB audit_events rows — never both. If the workflow query answered, every DB-only row (risk_escalated, second-approval events, domain events) was silently dropped; if the workflow was gone (seed-only or completed-and-evicted cases), the in-memory trail was dropped instead. Officers saw a different "audit log" depending on which backend happened to answer.
  2. Dead summary source. The case page's History summary read caseData.audit_log, which the GET /cases/{id} DB fallback populates from additional_data.workflow_state.audit_events — a key nothing writes. The collapsed panel claimed "No audit events yet" above a panel that, when opened, listed events.

Decision

  1. Terminal agent transitions are mirrored into the immutable chain. agent_progress_service.update_status — already the single choke point every pipeline agent reports through — additionally writes an audit_events row on SUCCESS/REUSED (agent_completed) and FAILED (agent_failed), through AuditService.log_event (the same ADR-0109 per-tenant hash-chained, advisory-lock-serialised writer used by persist_audit_event; the chain is reused, never reimplemented). Details carry agent_name, iteration, status, model, findings_count, duration_ms, prompt_version_id (from the #369 PromptProvenance contextvar — never fabricated in filesystem-fallback mode), plus error_message on failure. RUNNING/PENDING/SKIPPED transitions are not audited (they are operational progress, not lifecycle facts of record).

    Best-effort by explicit contract: the audit write runs after the agent_executions commit inside try/except; a failure never blocks the pipeline, but is never silently absent — it logs a LOUD logger.warning with full context and traceback naming the missing transition.

  2. The audit view is a union, never either/or. get_audit_log now always reads BOTH sources and merges them via a pure merge_audit_events: dedupe on (event_type, normalised timestamp) (Temporal's str(workflow.now()) and the DB's naive isoformat() are normalised to naive UTC so the same instant dedupes across formats; the Temporal copy wins a collision), sorted ascending. Unparsable timestamps sort first — an event without a readable clock is still an event, never dropped.

  3. The History summary reads the merged trail. A new AuditLogSummary component uses the same ["auditLog", workflowId] react-query key as the AuditLog list (shared cache, one fetch). "No audit events yet" renders only when the merged set is truly empty; a fetch error renders an explicit "audit trail unavailable" state — fail closed, a broken fetch is not an empty trail.

Consequences

Positive

  • Agent lifecycle facts (including which prompt version drove each agent, #369) are now tamper-evident: covered by the ADR-0109 per-tenant hash chain and the ADR-0064 immutability controls, retrievable per case for the regulator pack.
  • One audit endpoint now tells one truth: DB-only compliance events and in-memory workflow events appear together, whichever backend answers.
  • The "No audit events yet"-over-a-full-panel contradiction is gone; the summary and the list can no longer disagree (same query, same cache).

Negative

  • audit_events grows by ~27 rows per investigation run. Acceptable: rows are small, per-case indexed, and AMLR retention wants them anyway; the ADR-0064 retention/purge machinery governs their lifecycle.
  • A collision of the same (event_type, timestamp) across genuinely distinct events (two events of the same type in the same microsecond) would dedupe to one row in the view — the immutable rows themselves are unaffected. Considered acceptable; the chain remains the source of truth.

Neutral

  • No migration: agent_completed/agent_failed are new event_type values in an existing table. No workflow signature changes.
  • agent_executions remains the operational read model for the pipeline UI; the audit rows are the compliance record, not a replacement.

Alternatives Considered

Alternative 1: Audit-write from every agent call site

Have each agent (osint, adverse_media, synthesis, …) log its own lifecycle audit event. Rejected: update_status is already the single terminal-transition choke point; N per-agent writers would drift into the exact unreconciled-generator-paths defect class the case-pack audit remediation spent a milestone killing.

Alternative 2: Make agent_executions itself immutable + hash-chained

Add trigger + chain columns to agent_executions. Rejected: the table is an upsert-in-place operational progress store (RUNNING → SUCCESS mutates the row by design); freezing it would break progress reporting, and a second parallel chain duplicates the ADR-0109 machinery for no gain over mirroring terminal facts into the existing chain.

Alternative 3: Endpoint-side synthesis of lifecycle events from agent_executions

Merge agent_executions rows into the /audit response at read time without writing audit_events rows. Rejected: a read-time projection from a mutable table is not an audit record — it inherits the table's mutability (claim-vs-check: it reports the control's shape, not its state) and never enters the tamper-evident chain.