Skip to Content
DocsProductionAudit log attribution

Audit log with per-user attribution

Every primitive in Loom writes audit entries when an AuditLog is configured. AuditEntry carries a top-level user_id field; the HMAC signature covers it, so tampering that swaps a user id breaks verification.

PrimitiveEntries emitted
Agentrun_started, run_completed, tool_call, tool_result
Workflowstep_started, step_completed, step_failed

Two backends behind the same protocol:

ClassStorageUse
InMemoryAuditLog(secret=...)Python listTests, notebooks, in-process probes.
FileAuditLog(path, secret=...)JSONL on diskProduction. Restart-recovery via seq counter.

Quick wiring

from loomflow import Agent from loomflow.security import FileAuditLog audit = FileAuditLog("./audit.jsonl", secret="prod-secret") agent = Agent("...", audit_log=audit) await agent.run("export my data", user_id="alice", session_id="s1")

Pass the same audit_log to a Workflow and step entries interleave with agent entries under the same session_id:

from loomflow import Workflow wf = Workflow.chain([step_a, step_b], audit_log=audit) await wf.run("...", user_id="alice")

What the default captures (and doesn’t)

The default audit log is compliance-friendly. It captures enough to prove what happened, but it doesn’t store the customer’s content verbatim:

  • Prompts get truncated at 500 characters.
  • The model’s final output isn’t recorded at all.
  • Tool results carry only ok, denied, error, and reason. The actual return value of the tool stays out of the log.

That’s the right shape for any regime that prohibits logging customer content. If you’re debugging an internal flow on synthetic data and want everything captured verbatim, you opt in.

Verbatim capture: audit_log={"scope_full": True}

Instead of constructing an AuditLog instance, you can hand Agent or Workflow a config dict. The resolver builds the right backend for you. Set scope_full: True and prompts, outputs, and full tool-result bodies all land in the log:

from loomflow import Agent agent = Agent( "You are a helpful assistant.", audit_log={ "name": "./audit.jsonl", # path; omit this to get an in-memory log "scope_full": True, # capture everything verbatim "secret": "my-org-hmac-key", # optional, for HMAC signing }, )

The same dict works on Workflow. One parameter, two primitives:

wf = Workflow.chain( [step_a, step_b], audit_log={"scope_full": True, "secret": "my-org-hmac-key"}, )

Internally the resolver wraps the backend in FullTranscriptAuditLog. Signatures still verify, because the wrapper just forwards entries through to the underlying log.

When to flip scope_full on:

  • Incident review where you need the exact prompts that hit the model.
  • Pre-prod debugging on synthetic data.
  • A short-window post-mortem, with PII redaction handled downstream.

Leave it off in production unless compliance has signed off. The default truncation isn’t a bug, it’s the safer default.

from loomflow.security import FullTranscriptAuditLog # (you don't usually import this; the resolver picks it for you # when you pass `scope_full: True` in the dict)

Per-user_id queries. Partitioned, not payload-scanned

# All entries for alice alice_entries = await audit.query(user_id="alice") # Entries for one session session_entries = await audit.query(session_id="s1") # Compose filters — alice's completed runs only done = await audit.query(user_id="alice", action="run_completed")

query() is a partitioned read; no payload digging required. The underlying storage indexes on user_id + session_id + action.

HMAC tamper detection

from loomflow.security.audit import verify_signature entries = await audit.query(user_id="alice") sample = entries[0] verify_signature(sample, secret="prod-secret") # → True # Mutate the payload — verification fails: tampered = sample.model_copy( update={"payload": {**sample.payload, "tampered": True}} ) verify_signature(tampered, secret="prod-secret") # → False # Wrong secret also fails — catches secret-rotation mistakes verify_signature(sample, secret="wrong-key") # → False

The signature is over the canonical serialized body. Additions, changes, or removals all break it. The signature covers user_id, so swapping a different user’s id into an entry breaks verification too.

Restart recovery

A fresh FileAuditLog against the same path scans the existing JSONL and resumes the seq counter. New entries don’t collide:

# Crash → restart. New FileAuditLog reads the existing file: restarted = FileAuditLog("./audit.jsonl", secret="prod-secret") before = (await restarted.query(user_id="alice"))[-1].seq # Run more work: await wf.run("more", user_id="alice") after = (await restarted.query(user_id="alice"))[-1].seq # `after > before`, no seq collisions.

Full worked example

The framework ships examples/12_audit_log.py exercising the four behaviors above with EchoModel (no API key needed):

python examples/12_audit_log.py

Output:

============================================================ Part 1 — InMemoryAuditLog (fast, ephemeral) ============================================================ Alice's audit entries: 2 Bob's audit entries: 2 Sample action chain: ['run_started', 'run_completed'] ============================================================ Part 2 — FileAuditLog (durable, JSONL) ============================================================ Workflow result: 'HELLO!' Wrote 2078 bytes to _audit_log_demo.jsonl Entries written: 6 Actions: ['workflow_started', 'step_started', 'step_completed', 'step_started', 'step_completed', 'workflow_completed'] ============================================================ Part 3 — Tamper detection via HMAC ============================================================ Original entry verifies: True Tampered entry verifies: False ← detects the change Wrong-secret check passes: False ← catches rotation mistakes ============================================================ Part 4 — Restart recovery (seq counter persists) ============================================================ Highest seq before new run: 6 Highest seq after new run: 12 Δ = 6 new entries appended, no seq collisions.

Read examples/12_audit_log.py.

Audit log vs telemetry. The audit log is for compliance: who did what, signed for non-repudiation. Telemetry is for ops: span trees, latency histograms, error rates. Two different consumers, two different retention policies. Wire both. They don’t overlap. See Telemetry.

Last updated on