THE MODEL

Three records, one thread: what a model-call log cannot tell your auditor

An agent produces three different kinds of evidence, and three different people come looking for them. Most tools keep only the first.

Data model 10 min read Key figure · 3 record types, 1 correlation id All posts

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.

RecordAnswersShapeAsked 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
One claim, one thread, three record types interleaved. Blue is money moving, amber is authority being checked, and the run sits underneath the whole sequence. Every row carries the same 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 itself

causation_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
Three integration points, no per-call discipline required. Middleware adopts the inbound header or mints a new id; a context variable carries it through the request; the HTTP client and the commit hook read it automatically. A developer writing a new endpoint gets correlation for free.

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.

Implementation note

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.

Thread CLM-2027-0418 3 record types · 21 rows
09:14:02businessfnol_received — claim opened, $0 reserve
09:14:08run15 steps, 4,820 tokens, $0.18
09:16:41authoritypost_reserve $400,000 — refused L2 held, L3 required
09:17:03authoritypost_reserve $250,000 — allowed
09:17:04businessreserve_changed — $50,000$250,000
11:04:22authoritysettle_claim $58,000 — escalated to CL-SUPERVISOR-02
11:09:47businessclaim_settled — $58,000 by human
One column, three record types, no reconstruction. The engineer expands the run row, the supervisor reads the authority rows, the auditor exports the business rows — all from the same thread, with the claim number as the only identifier anyone needs to know.

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_commit hook 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 →