An agent doing consequential work produces three different kinds of evidence. Three different people come looking for them, at three different times, asking three different questions. Most observability tools keep only the first kind — which is why the other two people end up in a spreadsheet.
This is the data model post. It is the one everything else on this blog refers back to, so it is worth being precise.
The three records
Take a single moment in our carrier: an AI adjuster raises a claim reserve from $50,000 to $250,000. That one moment generates three records that look nothing alike.
| Record | Answers | Shape | Asked by |
|---|---|---|---|
| The run | What steps did the agent take, in order, and what did each one cost? | A tree of spans — think, call a tool, read the result, think again | Engineer, days later, debugging |
| The authority decision | What did it ask to do, what was it permitted to do, and who had to sign? | A row per gate: allowed, refused, escalated or downgraded | Supervisor, same week, spot-checking |
| The business event | What actually changed, by how much, and on whose authority? | Append-only: entity, field, old value, new value, actor, reason | Auditor, eighteen months later |
The important part is that you cannot derive any one of these from the others.
From the run alone you can see a tool called post_reserve and returned 200. You
cannot see whether the agent was allowed to post that reserve, or whether a supervisor had to
approve it, or what the reserve was before. From the business event alone you can see the number
changed from $50,000 to $250,000 — but not why the agent thought so, and not that it first tried
$400,000 and was refused. From the authority record alone you know a gate was hit, but not what
happened next.
Three records, or you are reconstructing the other two from memory in a meeting eighteen months from now.
The thread that ties them
Three record types on their own are three disconnected tables. What makes them a single story is a pair of identifiers, and getting these right is most of the work.
flowchart TD
subgraph thread["correlation_id = CLM-2027-0418 (the business thread)"]
direction TB
A["FNOL received
business event"] --> B["Agent run starts
run / spans"]
B --> C["Reserve $400k requested
authority decision: refused"]
C --> D["Reserve $250k posted
authority decision: allowed"]
D --> E["Reserve changed 50k to 250k
business event"]
E --> F["Settlement $58k requested
authority decision: escalated"]
F --> G["Supervisor approves
business event, actor = human"]
G --> H["Claim closed, reserve released
business event"]
end
style A fill:#e2f5f9,stroke:#0e97b0
style E fill:#e2f5f9,stroke:#0e97b0
style G fill:#e2f5f9,stroke:#0e97b0
style H fill:#e2f5f9,stroke:#0e97b0
style C fill:#fbede3,stroke:#b4531b
style D fill:#fbede3,stroke:#b4531b
style F fill:#fbede3,stroke:#b4531b
correlation_id.correlation_id — the business thread
This is the identifier of the thing the work is about. Not a tracing id. The claim number. The submission reference. The policy number.
This sounds like a small decision and it is the single highest-leverage one in the whole design. When the correlation id is the business reference, an underwriter and an SRE search the same box for the same string and both get an answer. When it is a generated UUID, you need a lookup table, and the lookup table is the thing nobody maintains.
# Wrong: correlation is a tracing concern, meaningless to the business
correlation_id = "7f3a9c21-4e88-4b02-9a17-2c5d1e0f8a44" # nobody can search this
# Right: correlation is the business reference, minted where the work begins
correlation_id = "VS-2027-0832950" # the submission itself
correlation_id = "CLM-2027-0418" # the claim itselfcausation_id — the immediate parent
Correlation tells you which story a record belongs to. Causation tells you what directly caused it. Without causation you have a flat list of forty events on a claim, in timestamp order, and no way to know that the reserve release was caused by the closure rather than merely following it.
The pair gives you both the set and the shape: correlation groups, causation nests. That is what lets a UI render a claim as a tree instead of a log.
How it propagates across services
The hard part is not defining the identifiers. It is carrying them across a service boundary without every developer having to remember to.
Our carrier spans five products — actors, underwriting, claims, ceded reinsurance and the observability layer itself. A single submission touches four of them. If correlation is passed as a function argument, it will be dropped, because someone will add a code path and forget.
flowchart LR
subgraph edge["Edge"]
M["Middleware
resolve() mints or adopts
the correlation id"]
end
subgraph ctx["Per-request context"]
CV["contextvar
Thread(correlation, causation)"]
end
subgraph out["Outbound"]
H["HTTP client
X-Correlation-Id
X-Causation-Id"]
end
subgraph db["Persistence"]
AC["after_commit hook
stamps audit rows"]
end
M --> CV
CV --> H
CV --> AC
H -.->|next service| M
AC --> LN["InsightLense"]
style M fill:#efecfe,stroke:#4b34e0
style CV fill:#e2f5f9,stroke:#0e97b0
style H fill:#efecfe,stroke:#4b34e0
style AC fill:#e2f5f9,stroke:#0e97b0
style LN fill:#fbede3,stroke:#b4531b
The rule that makes this work: middleware mints when absent, adopts when present.
A request arriving with X-Correlation-Id joins that thread. A request arriving without
one starts a thread. There is no third case, and no code path that produces an unthreaded write.
Emit business events from an after_commit hook, not from your service methods.
An event emitted before the transaction commits is a lie if the transaction rolls back, and we
have shipped that bug. The commit hook also means you cannot forget: any write that lands in the
database gets its event.
What each person actually does with this
The engineer, on Tuesday
A submission is stuck. They open the run, see fifteen spans, and find that step 9 called an address-validation tool that timed out twice and returned a partial result on the third attempt, which the agent then treated as authoritative. Total cost of the run, $0.31. They fix the retry handling. The business event and authority records were not needed.
The supervisor, on Friday
Spot-checking the week's settlements. They do not care about spans. They filter authority records for their team, see the eleven escalations, and read the note each approver left. Two of them were approved without a note, which is a conversation to have on Monday.
The auditor, eighteen months later
Reviewing how reserves were set in a quarter. They do not know what a span is and should not have to. They query business events where entity type is claim, field is reserve, and actor kind is agent. They get old value, new value, actor, timestamp, reason and the approval that authorised any change above the limit — as rows, exportable, immutable.
Three people, three tools, one underlying thread. Nobody had to reconstruct anything.
Why model-call logs cannot do this
An LLM observability tool records the model call: prompt, completion, tokens, latency, model version, maybe the tool name. That is genuinely valuable and we ingest it — we accept standard OTLP precisely so those traces land in the same place.
But look at what is structurally absent. A model-call log has no concept of an entity, so it cannot tell you which claim. No concept of a value, so it cannot tell you $50,000 became $250,000. No concept of authority, so it cannot tell you a refusal happened. And no concept of a human actor, so the supervisor's approval — the most important event in the whole sequence — is not in it at all, because no model was called when a person clicked approve.
The moment a human approves something, a model-call log goes blind. That is the moment an auditor cares about most.
What this looks like in InsightLense
The three record types are three tables. What makes them a product rather than a schema is a view that interleaves them on one thread, so the reserve change, the refusal that preceded it and the reasoning that produced it sit in a single chronological column.
This view is the whole argument for the model. The $400,000 refusal at 09:16 and the $250,000 posting at 09:17 are twenty-two seconds apart; in three separate systems they are two unrelated facts, and the story that the agent tried something larger and was stopped simply does not exist anywhere.
Adopting this incrementally
You do not need to rebuild anything to start. The order that worked for us:
- Correlation first, everywhere. Middleware plus a context variable plus outbound headers. Do this before adding any new record type; it is what makes the rest useful. Use a business reference, not a UUID.
- Business events second. Start with the three or four writes that move money
or change a legal position. An
after_commithook on those tables, old value and new value, actor and actor kind. Resist the urge to emit an event for everything — a ledger of noise is a ledger nobody reads. - Authority records third, once you have somewhere to put them. If your permission checks currently raise exceptions, converting them to returned values is the prerequisite.
- Runs last. If you already have OTLP instrumentation, this is a config change rather than a project.
The sequencing matters because each layer is more useful once the one before it exists. Business events without correlation are a table you cannot join. Authority records without business events tell you a gate fired but not what it protected.
Three records. One thread. That is the whole model, and the rest of this blog is what you can do once you have it.
Where do your agents already act on real records?
Tell us that, and what you would need to prove about those actions to an auditor. We will set up a hands-on walkthrough within two weeks.
Request a demo →