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.
| Primitive | Entries emitted |
|---|---|
Agent | run_started, run_completed, tool_call, tool_result |
Workflow | step_started, step_completed, step_failed |
Two backends behind the same protocol:
| Class | Storage | Use |
|---|---|---|
InMemoryAuditLog(secret=...) | Python list | Tests, notebooks, in-process probes. |
FileAuditLog(path, secret=...) | JSONL on disk | Production. 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, andreason. 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") # → FalseThe 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.pyOutput:
============================================================
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.