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_eventper 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}/auditgains 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_failedrow carrying agent_name, iteration, model, findings_count, duration_ms, prompt_version_id (and error_message on failure) — joinable toagent_executionsand to the prompt registry. A failed audit write is LOUD (logger.warningwith 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_statusis 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):
- Either/or audit view.
GET /cases/{id}/auditreturned the Temporal in-memory workflow events or the DBaudit_eventsrows — 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. - Dead summary source. The case page's History summary read
caseData.audit_log, which theGET /cases/{id}DB fallback populates fromadditional_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
-
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 anaudit_eventsrow on SUCCESS/REUSED (agent_completed) and FAILED (agent_failed), throughAuditService.log_event(the same ADR-0109 per-tenant hash-chained, advisory-lock-serialised writer used bypersist_audit_event; the chain is reused, never reimplemented). Details carryagent_name,iteration,status,model,findings_count,duration_ms,prompt_version_id(from the #369PromptProvenancecontextvar — never fabricated in filesystem-fallback mode), pluserror_messageon 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_executionscommit insidetry/except; a failure never blocks the pipeline, but is never silently absent — it logs a LOUDlogger.warningwith full context and traceback naming the missing transition. -
The audit view is a union, never either/or.
get_audit_lognow always reads BOTH sources and merges them via a puremerge_audit_events: dedupe on(event_type, normalised timestamp)(Temporal'sstr(workflow.now())and the DB's naiveisoformat()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. -
The History summary reads the merged trail. A new
AuditLogSummarycomponent uses the same["auditLog", workflowId]react-query key as theAuditLoglist (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_eventsgrows 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_failedare newevent_typevalues in an existing table. No workflow signature changes. agent_executionsremains 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.