AI Research Library
Observability That Explains Decisions
Rajesh Ranjan Mahapatra
A consequential AI system is observable only when a reviewer can reconstruct what evidence it used, what authority permitted the action, what effect actually occurred and what the trace deliberately did not copy.
© 2026 Rajesh Ranjan Mahapatra. 32 min read
Observability That Explains Decisions
A consequential AI system is observable only when a reviewer can reconstruct what evidence it used, what authority permitted the action, what effect actually occurred and what the trace deliberately did not copy.
TLDR
- A useful decision trace is a typed causal record, not a transcript of everything the model saw or generated.
- Its durable spine links intent, identity, evidence, policy, proposal, approval, action, verified effect, release and cost.
- Keep the compact decision spine for every consequential action; sample richer diagnostic telemetry according to risk and operating need.
- Raw prompts and chain-of-thought can aid debugging, but they are privacy-heavy and cannot certify why an action was justified.
- Instrumentation is sufficient only when it answers the review questions while capturing the least sensitive content needed to answer them.
The payment that returned 200
A payment-repair agent reviews a synthetic £18,400 transfer held as a possible duplicate. It retrieves the transaction history, compares reference fields, checks the current release policy, proposes removing the hold and routes the proposal to an operations reviewer. The reviewer approves. The payment API returns HTTP 200. Every service dashboard is green.
Two days later, an investigator asks a simple question: why was this payment released, and did release actually occur? The application log contains a model response saying the duplicate risk was low. The distributed trace shows a policy call, a human-workbench call and a successful payment API span. The prompt log contains the payer name, beneficiary account, free-text narrative and several pages of transaction history. Yet the record does not identify which evidence supported the material claim, which policy version allowed release, whether the approval preceded execution, or whether the downstream payment state changed.
The system recorded a great deal and explained very little. Worse, its observability platform now holds a second copy of sensitive customer data. The failure is architectural. Conventional telemetry followed the request through software. It did not preserve the decision through evidence, authority and effect.
The trace is not the decision
Distributed tracing was designed to follow work across services. Dapper popularised the low-overhead trace of causally related operations, while W3C Trace Context standardised how a trace identity can cross process boundaries.[2][3] OpenTelemetry adds spans, events, attributes and links. These mechanisms answer valuable questions: where time was spent, which dependency failed, which release generated an exception and which asynchronous tasks belonged to one request.[6]
A consequential decision demands a different query. A reviewer needs to know what was believed at the decision point, which evidence made that belief supportable, what policy and authority applied, what action was proposed, who or what authorised it, what effect followed and which system release produced the sequence. Latency is one property of this path. It is not its organising principle.
Three records are routinely conflated. An operational trace follows execution through services and queues. It is optimised for latency, errors and dependency diagnosis. A decision record follows the commitments that made an action defensible: subject, state, evidence, proposal, policy, approval and verified effect. An evidence store retains the governed source material that an authorised reviewer may need to inspect. One platform may transport all three, but their purposes, schemas, access rules and retention periods are different.
The distinction becomes visible during an incident. An operational trace can prove that the retrieval call completed in 82 milliseconds. The decision record must prove which returned items were admitted as evidence and which material reason codes depended on them. The evidence store must permit the reviewer to inspect the exact source version, subject to the same or stronger entitlement that applied at decision time. Treating these as one giant log makes common queries slow, makes access excessively broad and makes deletion or legal hold difficult to govern. Treating them as unrelated destroys reconstruction. The design problem is to link them without collapsing them.
This distinction also limits what natural-language reasoning can prove. Research has shown that chain-of-thought explanations can omit factors that affected an answer and can rationalise biased outcomes.[13] More recent tests of reasoning models likewise found that verbalised traces often failed to reveal the use of inserted hints.[14] A credible competing interpretation is that non-verbalisation can reflect incomplete compression rather than a false account, and that alternative metrics or larger reasoning budgets can change a faithfulness judgement.[16] That objection narrows the claim: a rationale may contain causally useful information, but it cannot certify its own completeness or faithfulness.
The central answer is therefore a typed causal record of observable commitments. Record which evidence was bound, which claim or reason code was emitted, which policy result applied, which action contract was invoked and which postcondition was observed. The record may include a rationale, but assurance rests on independently checkable events.
An observable commitment is deliberately weaker than a claim about inner cognition and stronger than an arbitrary log line. It states what the configured system was prepared to rely on or do at an interface. For example, rationale.recorded can commit to reason code duplicate_not_confirmed, a calibrated confidence band and two evidence references. It does not assert that those symbols exhaust the model's internal computation. A policy event commits to the evaluated input digest and returned obligations. An action event commits to an exact typed command. An effect event commits to a readback or to the honest state unknown. Explanatory power comes from commitments that can be contradicted by other records, not from prose that merely sounds plausible.
flowchart TD A["The payment that returned 200"] --> B["On this page"] B --> C["The trace is not the decision"] C --> D["Build a causal spine"] D -. "re-test" .-> A classDef first fill:#0a1424,stroke:#B96D00,stroke-width:2px,color:#e6f3ff classDef step fill:#0a1424,stroke:#3f7da0,stroke-width:1px,color:#e6f3ff class A first class B,C,D step
Build a causal spine
A request trace is usually represented as a tree: one span has at most one parent, and child spans inherit a trace identity. Decisions often form a graph. A model call may depend on several retrieved items. A policy decision may use identity, purpose, resource and risk tier from different services. A final action may join a model proposal with a human approval. OpenTelemetry span links can represent such fan-in and cross-trace relations, while W3C PROV supplies the broader vocabulary of entities, activities and responsible agents.[6][5]
Physical timestamps remain useful for operations, but they should not carry causal meaning alone. Lamport's happened-before relation showed why distributed event order is partial: an event can influence another through process order or message transmission even when clocks disagree.[1] A decision trace should therefore store explicit parent and link identifiers. Time helps the investigator. Edges establish the dependency.
Correlation alone is still too weak. Two events can share a trace identifier without one supporting the other. The edge therefore needs a role. Useful roles include derived_from for a reason linked to evidence, evaluated_under for a proposal linked to policy, authorised_by for an action linked to approval, executed_as for a proposal linked to a command and verified_by for a command linked to readback. These names turn a bag of events into an argument that software can inspect. A validator can reject an execution that merely occurred after an approval but was not the command that approval covered.
Typed edges also prevent accidental authority laundering. Suppose a reviewer approves “request one additional document”, while a later agent action releases funds. Both events belong to the same case and the approval precedes the release. A timeline might make the release look reviewed. An authorised_by edge must fail because the approved action type, resource and parameters do not match. Causal proximity is not delegated authority. The trace must bind the scope of the approval, not merely its presence.
The ten observable commitments
The spine can be expressed through ten commitments. They align with a broader control taxonomy while remaining implementable as events:
| Commitment | What the trace must establish | Typical record |
|---|---|---|
| Intent | The requested task, purpose and declared boundary. | Task reference and purpose code. |
| Identity | The user, agent, sub-agent and executing principal. | Pseudonymous principal references and delegation chain. |
| World state | The authoritative case or resource version at the decision point. | State snapshot reference and version. |
| Context and evidence | Which sources were available and which supported material claims. | Evidence references, digests, coordinates and freshness. |
| Reasoning | The structured proposal and its material reason codes. | Claim IDs, uncertainty, alternatives and concise rationale. |
| Authority | The evaluated policy, decision, obligations and approval. | Policy ID, version, input digest, result and approval reference. |
| Action | The exact typed command proposed and executed. | Action contract, resource token, idempotency key and command receipt. |
| Outcome | The state reached, including unknown or compensated outcomes. | Effect receipt or independent readback. |
| Release | The configured system that produced every event. | Workflow, model, prompt, tool and policy release IDs. |
| Economics | The marginal resources used by the decision path. | Latency, tokens, tool charges, retries and human review time. |
Two receipts deserve special treatment. A decision receipt binds the proposal to evidence, policy and approval. An effect receipt proves what the external system did. They are distinct because a valid decision can fail to execute, while an unauthorised action can execute successfully. Combining both into a single “success” flag destroys the very distinction an investigator needs.
Decision-time reconstruction also needs several notions of time. occurred_at records when the event happened according to its producer. recorded_at exposes late arrival or telemetry backlog. Evidence and policy need a validity interval because a source can be correct for one period and obsolete for another. An approval needs an expiry, especially when the resource state can change before execution. Release identifiers need effective intervals so an investigator does not infer configuration from a deployment calendar. These fields are not substitutes for causal edges. They answer a different question: was each dependency applicable when the decision used it?
hold_status=released. In run B, a later database conflict leaves hold_status=active. The decision receipt is identical; the effect receipt differs. A trace that stops at API acceptance cannot distinguish completion from an unknown outcome.
A concise completeness test follows. For an executed action a, the trace is decision-sufficient only when five predicates hold:
Bound links the intended subject and current state. Supported links material reasons to evidence. Permitted links the action to policy, authority and satisfied obligations. Observed proves the postcondition or records an explicit unknown outcome. Versioned binds the configured system. A fluent rationale cannot compensate for any missing predicate.
Observe without copying the world
The obvious response to missing evidence is to log everything. It is also the easiest way to create a high-value privacy and security target. Prompts can include names, financial identifiers, health data, legal documents, confidential instructions, access tokens and retrieved records. Tool responses can contain even more. OWASP's logging guidance explicitly lists access tokens, sensitive personal data, passwords, keys and bank-account or card data among information that should not be logged in ordinary application records.[12]
Standards make the same trade visible. W3C Baggage can propagate arbitrary values across services, so its specification warns against carrying confidential information across trust boundaries.[4] OpenTelemetry's GenAI event conventions allow capture of user input and model output, but those conventions are currently marked Development, and content capture remains a deliberate choice rather than a universal control record.[8] OpenTelemetry guidance recommends preventing collection where possible and provides collector processors for filtering, redaction and transformation.[9]
The trace index should point to evidence, not become an evidence warehouse. Store stable references, content digests, source coordinates, classifications, validity times and the access decision that permitted retrieval. Keep the underlying document in its governed system or evidence vault. An authorised investigator can resolve the reference when needed. Most dashboards and automated checks can operate on metadata alone.
A reference is not automatically privacy-preserving. It can itself reveal that a person has a fraud case, a medical condition or a legal dispute. It can also become useless if the target is overwritten, deleted without a tombstone or resolved under today's permissions rather than the permissions appropriate to the review. A defensible reference therefore needs an opaque identifier, source system, immutable version or digest, classification, validity interval and resolution policy. The trace should record that access was permitted, but it should not copy the credentials or policy inputs that granted access. Resolvability is a governed capability, not a convenient URL.
Five capture modes
Every potentially sensitive field should declare a capture mode rather than inherit an implicit “log it” default:
| Mode | Stored in the trace | Appropriate use |
|---|---|---|
none | Nothing. | Secrets, access tokens and content with no defensible observability purpose. |
digest | A keyed or cryptographic digest. | Checking that the same immutable input was evaluated, without retaining it. |
reference | A governed object reference and version. | Documents, customer records and large tool results. |
redacted | A purpose-specific projection with sensitive fields removed. | Human-readable incident investigation where metadata alone is insufficient. |
metadata | Typed low-sensitivity fields. | Policy IDs, reason codes, model release, token counts, latency and effect status. |
Open Policy Agent demonstrates the same principle at the policy layer. Its decision logs can contain input and result data, and it provides a masking policy that runs before logs are uploaded.[10] The useful generalisation is that redaction belongs before export, not after analysts discover that sensitive data has spread. A local collector or gateway should enforce an allowlist, reject prohibited fields and route high-sensitivity evidence references to a separate store.
NIST SP 800-53 gives a durable audit baseline: an audit record should establish event type, time, location, source, outcome and associated identity, while its enhancement AU-3(3) calls for limiting personally identifiable information to elements identified by a privacy risk assessment. AU-9 requires protection against unauthorised access, modification and deletion.[11] These are not AI-specific prescriptions, but they expose the right engineering tension: enough content to audit, limited content to reduce privacy risk, and strong protection for what remains.
Privacy control has three enforcement moments. At instrumentation, libraries should expose safe typed methods rather than accept arbitrary dictionaries from application code. At collection, a local processor should allowlist fields, transform identifiers, enforce size limits and reject raw secret-bearing payloads before network export. At analysis, the store should separate indexes from protected content, constrain field-level access, record analyst queries and apply retention or legal-hold rules by classification. Redaction performed only in the dashboard is too late because the original value has already crossed services, entered buffers and perhaps reached several vendors.
These controls need tests. Seed synthetic prohibited values into prompts and tool results, then verify that none reaches exported spans, error messages or dead-letter queues. Rotate a source document and confirm that the old reference either remains resolvable under its retention rule or returns an explicit tombstone. Attempt resolution as an unauthorised reviewer. A privacy-minimised trace is not established by a schema diagram. It is established by failure injection across the telemetry path.
Spend telemetry where consequence lives
Tracing every model message, retrieved passage, tool payload and intermediate state can be expensive. Sampling is a normal response. OpenTelemetry supports record-and-drop decisions, probability sampling and tail sampling based on completed trace properties such as error or latency.[7][15] That works well for diagnostic telemetry. It becomes dangerous when the sampled trace is also the only audit evidence for a consequential action.
A rare bad decision may look operationally successful. It can return 200 quickly, produce no exception and remain unnoticed until a complaint or downstream loss. Tail sampling rules based on error and latency will drop exactly the case later needed. Audit completeness and diagnostic richness therefore need different retention contracts.
The practical design is a two-plane trace:
The decision spine is compact, typed and retained for every consequential proposal or action. It contains identifiers, causal edges, evidence references, policy result, approval, action contract, effect status, release lineage, privacy metadata and cost summary. The diagnostic envelope contains richer spans, model messages, retrieval scores, tool payload projections and stack traces. It is sampled, short-lived and access-restricted.
The two planes should meet through explicit promotion rules. A denied high-consequence proposal, an unknown effect, a policy obligation failure, a human override or a novel tool error can promote the diagnostic envelope to a protected incident tier. Low-risk successful runs may retain only a probability sample. Promotion must happen while the richer data is still in a controlled buffer. It cannot recover content that instrumentation never collected, which is a feature for prohibited fields and a limitation for unforeseen investigations.
Promotion also needs a budget. Without one, every team marks its events “critical” and the selective envelope becomes permanent bulk logging. Define maximum promoted volume, retention, access and review ownership for each consequence class. When the budget is exhausted, preserve the decision spine, record the diagnostic loss and alert the owner rather than silently dropping both planes. The durable receipt must degrade more slowly than the debugging detail.
Make cost a causal property
Cost dashboards usually aggregate by model, team or endpoint. A decision trace should also attribute cost to the path that created business value or review burden. Current OpenTelemetry GenAI conventions include input and output token attributes and seek to align reported usage with billed counts where providers expose them.[8] Add tool charges, retrieval calls, retries, verifier runs and human review time. A £0.02 generation that triggers three verifiers and ten minutes of review is not a £0.02 decision.
Cost attribution changes design choices. It can show that one expensive source removes two later model calls, that a verifier catches little at high cost, or that retries dominate the long tail. It can also expose perverse savings. If a team reduces telemetry spend by dropping effect receipts, the apparent unit cost falls while unknown outcomes rise. The trace should make that trade visible.
Unit economics also needs a meaningful denominator. Cost per model call rewards fragmentation tricks and says little about outcomes. Better denominators include cost per verified action, per correctly deferred case, per reviewed exception or per recovered failure, depending on the task. Report the distribution, not only the mean: retries, long contexts and human escalations often concentrate cost in a small tail. Keep these numbers beside effect status and consequence class so a cheaper path cannot look superior merely because it externalised investigation or correction work.
Here T is a proposed trace design, Storage includes ingestion, indexing and retention, Exposure represents the sensitivity and spread of captured content, and λ expresses the organisation's privacy weighting. The constraints are concrete questions: which evidence supported the action, which policy applied, who approved, what effect occurred and which release ran. The equation is a design discipline, not a measured universal law.
Worked example: the payment release reconstructed
Return to the synthetic £18,400 payment. The agent's intended use is bounded: gather evidence, assess whether the duplicate indicator is supported, retrieve the current release policy and propose a hold disposition. It cannot release the payment without a named reviewer. The payment service alone can execute the command. A separate readback checks the resulting state.
The decision spine contains ten events. The raw payer name, beneficiary account and narrative never enter the trace index. Instead, a tokenised resource reference binds the payment; an evidence-vault reference identifies the retrieved transaction bundle; a digest proves which bundle version was used; reason code duplicate_not_confirmed records the material proposal; policy payment-release@2026.08.17 returns allow with obligation human_approval; the approval and command carry their own principals and receipts; readback proves hold_status=released; the final event records 1,380 input tokens, 214 output tokens and the synthetic cost.
Now the investigator can ask executable questions. Did the evidence bundle exist and remain valid at decision time? Did the material reason code link to it? Did the correct policy version return allow? Was human approval a causal predecessor of execution? Did the command target the same tokenised payment bound at identity resolution? Did readback prove the intended state exactly once? Which release and cost produced the result? Each answer comes from typed fields or an authorised evidence resolution, not from searching prose.
Vary one field and the trace localises a different failure. If the policy event is absent, the system may have produced a plausible proposal but cannot show permission. If the approval exists without a scoped edge to the command, the human may have reviewed a different action. If the command receipt exists without readback, the outcome is unknown rather than successful. If the evidence digest differs from the version resolved by the reviewer, the case has a provenance defect. If all causal fields are complete but the release identifier is missing, the decision may be understandable yet not reproducible. This intervention discipline is the reason for typed events: each missing relation predicts a specific uncertainty.
The related trajectory-assurance problem begins after this instrumentation exists: which properties should be scored, sampled and reviewed? This article therefore narrows the adjacent trajectory assurance analysis. That work asks whether a route was sound. The present design asks what the runtime must emit so the route can be tested at all.
stateDiagram-v2 [*] --> Claim state "The ten observable commitments" as Claim state "Five capture modes" as Test state "Make cost a causal property" as Revision Claim --> Test: expose mechanism Test --> Decision: evidence survives Test --> Revision: rival survives Revision --> Claim: narrow or rebuild Decision --> [*]
Instrument the contract, not the story
The schema below is a practitioner proposal, not an OpenTelemetry standard. It maps cleanly to common trace primitives: trace_id and event identifiers supply correlation; parents and links supply causal structure; typed attributes supply searchable decisions; source references supply provenance; and separate logs or object stores can hold protected evidence. Current GenAI semantic conventions are useful implementation inputs, but their Development status means a production schema should version its own contract and map vendor fields at the boundary.[8]
Treat that schema as an interface with compatibility rules. Producers should declare a schema version; collectors should reject unknown required fields rather than quietly flatten them; consumers should state the oldest and newest versions they understand. Adding an optional cost field is different from changing what effect_status=verified means. Semantic changes require a new version, migration tests and a period in which old receipts remain readable. The trace contract should be released with the workflow, not edited independently in a dashboard after deployment.
Ownership matters as much as syntax. Domain teams define material reason codes and authoritative effects. Security and privacy teams define prohibited capture and access classes. The platform team supplies libraries, propagation, storage and query controls. Independent validation defines the review questions and failure injections. When one team owns all four, missing evidence tends to be normalised as a tooling limitation. A receipt is credible only when its producer does not unilaterally define sufficiency.
| Field group | Required fields | Why it exists |
|---|---|---|
| Correlation | trace_id, event_id, parents[], links[], occurred_at | Reconstruct partial order, fan-out and fan-in. |
| Actor and release | actor, principal_ref, delegation_ref, release_id | Separate the requester, proposer, approver and executor; bind behaviour to a configured release. |
| Event semantics | event_type, layer, reason_codes[], status | Make the event queryable without interpreting free text. |
| Evidence | object_refs[], digests[], valid_time, source_version | Resolve what the system knew without copying source content. |
| Authority | policy_id, policy_version, input_digest, decision, obligations[] | Prove why a proposal was permitted, denied or routed for review. |
| Action and effect | action_type, resource_ref, idempotency_key, command_receipt, effect_status, effect_receipt | Distinguish accepted command from verified postcondition. |
| Privacy | classification, capture_mode, retention_class, access_policy | Make collection and retention enforceable properties of each event. |
| Economics | latency_ms, input_tokens, output_tokens, tool_cost, review_seconds | Attribute marginal cost to the decision path. |
Executable artefact: a decision-receipt validator
The companion Python script uses only the standard library. It builds one valid synthetic trace and one negative case. The validator checks unique identities, causal references, allowlisted index fields, policy version and result, required predecessors, human approval for consequential execution, verified effect, release lineage and token-cost capture.
Inspect the implementation in this paper#!/usr/bin/env python3
"""Reference validator for a privacy-minimised decision trace.
The script uses only Python's standard library. It builds one valid synthetic
trace and one deliberately broken trace, then checks causal completeness,
authority, verified effect, release lineage, cost capture, and an index-field
allowlist.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from hashlib import sha256
from typing import Any, Iterable
ALLOWED_INDEX_FIELDS = {
"case_ref",
"evidence_ref",
"claim_code",
"policy_id",
"policy_version",
"decision",
"obligations",
"action_type",
"resource_ref",
"effect_status",
"receipt_ref",
"input_tokens",
"output_tokens",
"cost_micro_usd",
}
PROHIBITED_FIELD_FRAGMENTS = {
"prompt",
"password",
"secret",
"access_token",
"customer_name",
"email",
"phone",
"iban",
"account_number",
"document_text",
"tool_result_raw",
}
REQUIRED_PREDECESSORS = {
"intent.received",
"identity.bound",
"evidence.bound",
"rationale.recorded",
"policy.decided",
"action.proposed",
}
@dataclass(frozen=True)
class Event:
trace_id: str
event_id: str
event_type: str
occurred_at: str
actor: str
principal_ref: str
release_id: str
parents: tuple[str, ...] = ()
links: tuple[str, ...] = ()
classification: str = "internal"
capture_mode: str = "metadata"
attributes: dict[str, Any] = field(default_factory=dict)
def iso(second: int) -> str:
base = datetime(2026, 8, 30, 4, 0, second, tzinfo=timezone.utc)
return base.isoformat().replace("+00:00", "Z")
def digest(text: str) -> str:
return "sha256:" + sha256(text.encode("utf-8")).hexdigest()
def ancestors(event_id: str, by_id: dict[str, Event]) -> set[str]:
seen: set[str] = set()
stack = list(by_id[event_id].parents + by_id[event_id].links)
while stack:
current = stack.pop()
if current in seen or current not in by_id:
continue
seen.add(current)
stack.extend(by_id[current].parents)
stack.extend(by_id[current].links)
return seen
def descendants(event_id: str, events: Iterable[Event]) -> set[str]:
reverse: dict[str, set[str]] = {}
for event in events:
for source in event.parents + event.links:
reverse.setdefault(source, set()).add(event.event_id)
seen: set[str] = set()
stack = list(reverse.get(event_id, set()))
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
stack.extend(reverse.get(current, set()))
return seen
def validate(events: list[Event]) -> list[str]:
errors: list[str] = []
by_id = {event.event_id: event for event in events}
if len(by_id) != len(events):
errors.append("event_id values must be unique")
trace_ids = {event.trace_id for event in events}
if len(trace_ids) != 1:
errors.append("all events in this receipt must share one trace_id")
for event in events:
for edge in event.parents + event.links:
if edge not in by_id:
errors.append(f"{event.event_id}: missing causal reference {edge}")
for key in event.attributes:
key_lower = key.lower()
if key not in ALLOWED_INDEX_FIELDS:
errors.append(f"{event.event_id}: field {key!r} is not allowlisted for the trace index")
if any(fragment in key_lower for fragment in PROHIBITED_FIELD_FRAGMENTS):
errors.append(f"{event.event_id}: sensitive field {key!r} must be omitted or replaced by a reference")
if event.capture_mode not in {"none", "digest", "reference", "redacted", "metadata"}:
errors.append(f"{event.event_id}: unsupported capture_mode {event.capture_mode!r}")
if not event.release_id:
errors.append(f"{event.event_id}: release_id is required")
policy_events = [event for event in events if event.event_type == "policy.decided"]
for policy in policy_events:
if policy.attributes.get("decision") not in {"allow", "deny", "review"}:
errors.append(f"{policy.event_id}: policy decision must be allow, deny, or review")
if not policy.attributes.get("policy_version"):
errors.append(f"{policy.event_id}: policy_version is required")
executed = [event for event in events if event.event_type == "action.executed"]
for action in executed:
prior_ids = ancestors(action.event_id, by_id)
prior_types = {by_id[event_id].event_type for event_id in prior_ids}
missing = REQUIRED_PREDECESSORS - prior_types
if missing:
errors.append(f"{action.event_id}: missing predecessor types {sorted(missing)}")
allowing_policy = [
by_id[event_id]
for event_id in prior_ids
if by_id[event_id].event_type == "policy.decided"
and by_id[event_id].attributes.get("decision") == "allow"
]
if not allowing_policy:
errors.append(f"{action.event_id}: no allowing policy decision is causally linked")
if action.classification == "consequential" and "approval.granted" not in prior_types:
errors.append(f"{action.event_id}: consequential action lacks a prior approval event")
later_ids = descendants(action.event_id, events)
verified_effects = [
by_id[event_id]
for event_id in later_ids
if by_id[event_id].event_type == "effect.observed"
and by_id[event_id].attributes.get("effect_status") == "verified"
]
if not verified_effects:
errors.append(f"{action.event_id}: no verified effect descendant proves the postcondition")
usage_events = [event for event in events if event.event_type == "usage.recorded"]
if not usage_events:
errors.append("trace has no usage.recorded event")
else:
for usage in usage_events:
for field_name in ("input_tokens", "output_tokens", "cost_micro_usd"):
value = usage.attributes.get(field_name)
if not isinstance(value, int) or value < 0:
errors.append(f"{usage.event_id}: {field_name} must be a non-negative integer")
return errors
def good_trace() -> list[Event]:
trace_id = "tr_01J6A4SYNTHETICPAYMENT"
release = "payment-repair-agent@4.3.1"
common = dict(trace_id=trace_id, actor="payment-repair-runtime", principal_ref="principal:agent-73", release_id=release)
return [
Event(event_id="e1", event_type="intent.received", occurred_at=iso(1), parents=(),
attributes={"case_ref": "case:PAY-4821"}, **common),
Event(event_id="e2", event_type="identity.bound", occurred_at=iso(2), parents=("e1",),
attributes={"case_ref": "case:PAY-4821", "resource_ref": "payment:tok_9c2"}, **common),
Event(event_id="e3", event_type="evidence.bound", occurred_at=iso(3), parents=("e2",),
capture_mode="reference", attributes={"evidence_ref": "vault:ev_77", "claim_code": digest("duplicate-check-clear")}, **common),
Event(event_id="e4", event_type="rationale.recorded", occurred_at=iso(4), parents=("e3",),
capture_mode="metadata", attributes={"claim_code": "duplicate_not_confirmed"}, **common),
Event(event_id="e5", event_type="policy.decided", occurred_at=iso(5), parents=("e4",),
attributes={"policy_id": "payment-release", "policy_version": "2026.08.17", "decision": "allow", "obligations": ["human_approval"]}, **common),
Event(event_id="e6", event_type="action.proposed", occurred_at=iso(6), parents=("e5",),
classification="consequential", attributes={"action_type": "release_hold", "resource_ref": "payment:tok_9c2"}, **common),
Event(trace_id=trace_id, event_id="e7", event_type="approval.granted", occurred_at=iso(7), parents=("e6",),
actor="operations-reviewer", principal_ref="principal:human-118", release_id=release,
classification="consequential", attributes={"case_ref": "case:PAY-4821", "decision": "allow"}),
Event(event_id="e8", event_type="action.executed", occurred_at=iso(8), parents=("e7",),
classification="consequential", attributes={"action_type": "release_hold", "resource_ref": "payment:tok_9c2", "receipt_ref": "cmd:rcpt_55"}, **common),
Event(event_id="e9", event_type="effect.observed", occurred_at=iso(9), parents=("e8",),
classification="consequential", attributes={"effect_status": "verified", "resource_ref": "payment:tok_9c2", "receipt_ref": "readback:rb_56"}, **common),
Event(event_id="e10", event_type="usage.recorded", occurred_at=iso(10), parents=("e9",),
attributes={"input_tokens": 1380, "output_tokens": 214, "cost_micro_usd": 7420}, **common),
]
def bad_trace() -> list[Event]:
events = good_trace()
# Remove the effect readback, then inject a prohibited raw identifier into the index.
events = [event for event in events if event.event_type != "effect.observed"]
events = [
replace(event, parents=("e8",)) if event.event_id == "e10" else event
for event in events
]
events[2] = replace(
events[2],
attributes={**events[2].attributes, "raw_iban": "GB00 SYNTHETIC 0000 0000"},
)
return events
def report(label: str, events: list[Event]) -> None:
errors = validate(events)
print(label)
if errors:
for error in errors:
print(f" FAIL: {error}")
else:
total_cost = sum(event.attributes.get("cost_micro_usd", 0) for event in events)
print(f" PASS: {len(events)} events form a complete decision receipt")
print(f" PASS: indexed cost = ${total_cost / 1_000_000:.6f}")
if __name__ == "__main__":
report("GOOD TRACE", good_trace())
print()
report("BROKEN TRACE", bad_trace())
Expected output and what the negative case changes
GOOD TRACE
PASS: 10 events form a complete decision receipt
PASS: indexed cost = $0.007420
BROKEN TRACE
FAIL: e3: field 'raw_iban' is not allowlisted for the trace index
FAIL: e3: sensitive field 'raw_iban' must be omitted or replaced by a reference
FAIL: e8: no verified effect descendant proves the postcondition
The altered trace changes only two properties. It copies a raw financial identifier into the index and removes the effect readback. The action remains approved and its API call remains successful. The validator still rejects the trace because privacy and postcondition evidence are independent release conditions.
A trace-sufficiency decision instrument
Before choosing a platform or adding another dashboard, walk one consequential decision through these eight tests. A “no” identifies a missing contract, not merely a missing field.
- Boundary: Can the trace name the intended subject, resource, task, purpose and authoritative state version?
- Support: Can every material reason code or claim resolve to evidence that existed and was permitted at the decision time?
- Authority: Does the record bind the policy version, evaluated input digest, result, obligations and approving principal?
- Action: Is the proposed and executed command typed, scoped to the same resource and protected by an idempotency key where effects can repeat?
- Outcome: Is there an effect receipt or independent readback, including explicit
unknownandcompensatedstates? - Lineage: Can the investigator identify the workflow, model, prompt, retriever, tool and policy releases without inferring them from deployment time?
- Privacy: Does every field have an allowlisted purpose, capture mode, classification, access policy and retention class before export?
- Economics: Can cost and review burden be attributed to the path rather than only to an endpoint or monthly invoice?
Where the mechanism stops helping
A trace records observables; it does not grant access to hidden causation. Evidence links show what entered the configured system, not which internal representation caused a model token. A reason code can show the proposition the system committed to, not guarantee that it faithfully reports hidden computation. This boundary is why the design relies on independent checks of evidence, policy, action and effect.
The trace also fails when its producers can rewrite history. Protect the receipt store from ordinary application credentials, use append-only or cryptographically protected records where consequence warrants it, and monitor the telemetry pipeline itself. NIST AU-9 treats audit settings, reports and tools as part of the protected audit surface.[11] If the system under review can silently delete its own adverse events, the dashboard is decorative.
Finally, the schema cannot resolve a missing authoritative state. If the payment system exposes no readback, the correct effect status is unknown. If policy is encoded only in a PDF and no evaluated version is recorded, the trace cannot invent the policy decision after the event. Observability makes system boundaries visible; it does not erase them.
Compact glossary
- Decision spine
- The minimal, durable and typed events retained for every consequential proposal or action.
- Diagnostic envelope
- Richer, sampled and short-lived telemetry used for performance diagnosis and incident investigation.
- Decision receipt
- A record binding evidence, reason codes, policy, obligations and approval to a proposed or executed action.
- Effect receipt
- A downstream acknowledgement or independent readback proving the resulting state.
- Capture mode
- The enforced choice to store nothing, a digest, a reference, a redacted projection or typed metadata.
- Unknown outcome
- A first-class state used when command acceptance is known but the postcondition cannot yet be proved.
What the trace must prove
Observability for compound AI should stop being measured by how much text a platform can display. The useful test is narrower: can an authorised reviewer reconstruct the subject, evidence, policy, approval, action, effect, release and cost without treating generated prose as self-authenticating evidence?
Keep a complete decision spine and a selective diagnostic envelope. The spine survives sampling because it is the record of consequential authority and outcome. The envelope remains flexible because debugging needs vary and sensitive content should not become permanent by default. Bind events with causal edges, retain references instead of copies, mask before export, protect the receipt store and record unknown outcomes honestly.
The architecture decision changes accordingly. Do not begin with “Which observability vendor captures the most?” Begin with the review questions, define the minimal typed receipts that answer them, and then choose tracing, policy, storage and privacy mechanisms that implement that contract. A system whose decisions cannot be reconstructed has not merely been observed poorly. It has acted without durable evidence.
sequenceDiagram participant Q as Question participant M as Mechanism participant E as Evidence participant D as Decision Q->>M: The payment that returned 200 M->>E: The ten observable commitments E-->>M: compare rival explanations E->>D: Sources D-->>Q: revise scope or act
Sources
- Primary paper Leslie Lamport, “Time, Clocks and the Ordering of Events in a Distributed System”, Communications of the ACM, 1978.
- Primary paper Benjamin H. Sigelman et al., “Dapper, a Large-Scale Distributed Systems Tracing Infrastructure”, Google Research, 2010.
- Official specification W3C, Trace Context.
- Official specification W3C, Propagation format for distributed context: Baggage.
- Official specification W3C, PROV-DM: The PROV Data Model.
- Official specification OpenTelemetry, Tracing API.
- Official specification OpenTelemetry, Tracing SDK.
- Official specification OpenTelemetry, Semantic conventions for Generative AI events and agent spans.
- Official guidance OpenTelemetry, Handling sensitive data.
- Official specification Open Policy Agent, Decision Logs.
- Official control catalogue NIST, SP 800-53 Rev. 5, Security and Privacy Controls for Information Systems and Organizations, especially AU-3 and AU-9.
- Authoritative guidance OWASP, Logging Cheat Sheet.
- Primary paper Miles Turpin et al., “Language Models Don't Always Say What They Think: Unfaithful Explanations in Chain-of-Thought Prompting”, NeurIPS 2023.
- Primary paper Yanda Chen et al., “Reasoning Models Don't Always Say What They Think”, 2025.
- Official guidance OpenTelemetry, Sampling.
- Competing primary paper Kerem Zaman and Shashank Srivastava, “Is Chain-of-Thought Really Not Explainability? Chain-of-Thought Can Be Faithful without Hint Verbalization”, 2025.