The AuditableCall row
The typed, append-only row shape that every LLM invocation writes to the audit ledger — identity, principal, call, decision, outcome, and typed payload slots.
This page is the audit-ledger row shape. For read-side observability (OTel spans, lineage, Datadog patterns), see Observability.
The row is one of three concepts in the audit-ledger cluster — the row (this page), the ProviderDecisionLedger write interface, and the hash chain that protects against after-the-fact tampers. See Audit ledger → the audit-ledger cluster for the cluster framing.
With an AuditEmitter registered, every LLM call writes one
append-only row to the audit ledger. The row carries tenantId,
turnId, toolName, subagentDepth, parentTurnId, modelId,
family, inputTokens, outputTokens, plus the optional typed
slots (familyLock, tokenCost, toolSelection,
synthesisQuality, interruptDecision, pluginPayloads, …). That's
the variable surface every replay, per-turn cost rollup, and "what
did the agent actually do" query reads from.
@pleach/core ships the typed row shape plus the emit seam; the
host binds the principal, the per-turn sequence, and the ledger
that stores the row. The default emitter is a no-op, so a deploy
that registers no emitter writes nothing — the quickstart and any
production host wire one, which is why the floor holds in every
real deployment.
Locked contract
The row shape is a cross-SKU contract. @pleach/core writes it;
@pleach/compliance adds the hash-chain columns;
@pleach/gateway reads tenantId for cost rollup;
@pleach/observe joins on turnId for span construction;
@pleach/eval reads the row to replay. Changing the column set
breaks every consumer — there is no plugin path or config field
that alters the structural columns (turnId, toolName,
modelId, family, inputTokens, outputTokens,
subagentDepth, parentTurnId). Schema changes are upstream
contribution only.
The extension surface lives in two places:
principal.tenantId— opaque to the row, scoped however your deployment wants (end-customer, employee, team, cost center).pluginPayloads[]— the typed, plugin-namespaced extension slot. Each entry carriespluginId,subKind, and an opaquedatapayload the plugin owns the shape of — the way a host attaches downstream-readable per-call context without coring out a new top-level slot.
Everything else is fixed. See
Ownership boundaries → @pleach/core
for the broader locked-vs-configurable map.
tenantId is opaque to the row. It carries whichever attribution
axis your deployment runs on — an end-customer id for a SaaS, or an
employee, team, or cost-center id when Pleach composes under an
Anthropic Workspace or OpenAI Project on an Enterprise contract.
See Migrating from Anthropic Enterprise
and Migrating from OpenAI Enterprise
for the contract-side framing.
With an emitter registered, every call the runtime makes — synthesize, reasoning, converse, utility, retries, fallbacks — writes one such row.
@pleach/core/auditSourcesrc/audit/Row shape
The row spreads the identity columns inline (via
extends AuditableCallIdentity) and groups the rest into four
required nested sub-shapes (principal, call, decision,
outcome) plus optional typed payload slots. The structure is locked
at AuditRecordVersion; bumps are gated by an upstream audit gate and
recorded in AUDIT_RECORD_VERSION_HISTORY.
Identity
Prop
Type
Principal
AuditableCallPrincipal is replicated onto every row so SOC2 user-action
queries don't need a session join.
Prop
Type
Call shape
AuditableCallShape is post-routing — what actually ran, not what was
requested.
Prop
Type
Decision
AuditableCallDecision names the policy that picked the rung.
Prop
Type
Outcome
AuditableCallOutcome carries the provider's coarse result.
Prop
Type
Typed payload slots
The variable surface — what the ledger has to carry because no
structural invariant can derive it. Each slot is stage-correlated
and optional. A tool-loop row MAY carry toolSelection; a
synthesize row MAY carry synthesisQuality. Consumer narrowing
happens on stageId.
| Slot | Wire shape | Where it fires |
|---|---|---|
familyLock | FamilyLockPayload | Family-lock resolution boundary; carries resolverPath, emitSite, requestedFamily, familyMismatch, byokActive, byokKeyId, resolvedViaAlias, legacyDefaultModel |
fallbackStep | FallbackStepRecord | Post-attempt of every fallback rung; carries originalFamily/originalModel/originalCallClass, attemptFamily/attemptModel/attemptCallClass, attemptIndex, reason (FallbackStepReason), inFamily, originalLadderStep, ladderStep, ladderRegression |
providerCascade | ProviderCascadeRecord | Graph decision-node cascade pivots; carries source (graph / imperative-carve-out), trigger (decision-throw / decision-retry / stream-error / max-retries / timeout / user-abort-confirmed), errorType, providerFrom, providerTo, attemptN, userAbortValid |
cacheBreakpoint | CacheBreakpointLog | Provider response boundary; carries fingerprintComposite, cacheReadTokens, cacheCreationTokens, inputTokens, hitPct |
toolFallbackStep | ToolFallbackStepRecord | Runtime tool-cascade pivot or class-exhaustion; carries originalTool, cascadedTo, capabilityId, failureClass (ToolFallbackFailureClass), attemptIndex, triedToolCount, inClass, cascadeApplied, optional triggerSite |
tokenCost | TokenCostRecord | Token / usage accounting — populated with the real input/output token counts the provider reported |
synthesisQuality | SynthesisQualityRecord | synthesize rows only |
toolSelection | ToolSelectionTrace | tool-loop rows only |
planGeneration | PlanGenerationRecord | anchor-plan rows only |
interruptDecision | InterruptDecisionRecord | When a human-in-the-loop interrupt fires |
pluginPayloads | readonly PluginAuditPayload[] | Plugin-namespaced extension slot (pleachfix #10, auditRecordVersion: 8). Each entry carries pluginId, subKind, and an opaque data payload the plugin owns the shape of — lets a plugin emit typed audit context without coring out a new top-level slot |
FallbackStepRecord.ladderRegression is the canonical example of
the variable surface earning its keep — a structural invariant says
in-family fallback walks the ladder forward; the column says when
this specific row went backwards. The lattice can't answer that;
the ledger does.
Joinability
sessionIdjoins to the session record.turnIdaggregates every row that fired in service of a single user message — the natural grain for cost reporting and replay.(sessionId, turnId, stageId, seqWithinTurn)is the idempotency key the ledger enforces onrecordCall.
Rows are append-only. The interface ships no update or delete
primitive; a row that needs to mutate is a wire-format break and
bumps auditRecordVersion instead. Deletion runs through the
GDPRSoftDelete plug-point on the ledger interface — a host
implementation today, with retention machinery a planned scope
extension for @pleach/compliance@0.1.0 (the SKU ships
the scrubber cohort + attestation today; tombstone-driven
retention is the next compliance phase).
Query patterns
The exact SQL column names are a property of your persistence adapter (Supabase, IndexedDB, S3). The shape below uses the in-memory shape names directly.
import { MemoryProviderDecisionLedger } from "@pleach/core/audit";
const ledger = new MemoryProviderDecisionLedger();
// every row for a turn, in seqWithinTurn order
const turn = await ledger.getTurn(sessionId, turnId);
// newest-first session window, ULID cursor pagination
const page = await ledger.getSession(sessionId, { limit: 100, before: cursor });
// streamed export by time range — yields chunks
for await (const chunk of ledger.streamByTimeRange({
fromIso: "2026-06-01T00:00:00Z",
toIso: "2026-06-02T00:00:00Z",
chunkSize: 500,
})) {
// forward to SIEM
}Rows in getTurn come back sorted by seqWithinTurn. Replay
reconstructs the fallback ladder by sorting
WHERE turn_id = X ORDER BY seqWithinTurn. The tool-cascade chain
narrows further: WHERE turn_id = X AND stageId = 'tool-loop' AND toolFallbackStep IS NOT NULL ORDER BY seqWithinTurn.
Retention
Default retention is indefinite. Per-tenant retention policies
are the next planned phase of
@pleach/compliance@0.1.0 — soft-delete (tombstone-then-purge)
is the design target as the GDPR-aligned default; hard-delete is
opt-in for tenants that contractually require it. Today the
shipping @pleach/compliance cut handles the scrubber + audit
contract substrate; retention scheduling stays host-side via the
GDPRSoftDelete plug-point.
Why this lives in core, not a sibling
Audit is what makes replay, eval, and fallback-rate dashboards
possible. Pushing it into a sibling would mean every consumer
choosing whether to ship audit at all — some wouldn't, and the rest
of the ecosystem couldn't assume a consistent floor. @pleach/core
ships the row; siblings layer retention, redaction, hash-chaining,
and replay on top.
Typed record payloads
Five record shapes were promoted from opaque JSON to typed,
stage-correlated slots: InterruptDecisionRecord, TokenCostRecord,
ToolSelectionTrace, PlanGenerationRecord, and
SynthesisQualityRecord. Each carries the shape its analytics
consumer actually needs, not a bag of unknown.
The audit-row version log carries the bumps (v8 through v19, all
additive — see AUDIT_RECORD_VERSION_HISTORY). Older readers keep
working; newer readers get the narrowed fields. The v18 bump added
resolved model/provider/token-usage values plus the caller's
requested model (decision.requestedModel); the most recent bump to
v19 added the outcome.why provider-degradation diagnostics (raw
native finish reason, serving provider, gateway attempt).
Each typed record is an optional named slot on the row. Consumer code
narrows by checking slot presence (if (call.toolSelection) { … })
and gets per-slot field narrowing without as casts.
For the per-record field shape and the derived analytics each one
unlocks, see /docs/typed-records.
Where to go next
Audit ledger
The ProviderDecisionLedger interface and the persistence adapters that write these rows.
Typed records
The five typed record slots and the analytics each one unlocks.
Tamper-evident hash chain
`prev_hash` + `row_hash` — what protects this row's persistence.
Observability
Read-side wiring — OTel spans, Datadog, Honeycomb — that joins to rows on `turnId`.
Scrubbers
The event-type allowlist and the four bundled scrubbers that gate harness_event_log writes.
Audit ledger
The ProviderDecisionLedger write interface, the AuditableCall row it persists, the three compliance plug-points, and how to wire your own adapter.
Tamper-evident hash chain
prev_hash + row_hash columns on the audit-ledger event log — what the chain proves, how rows get verified, and how the default-on writer-side stamping ships in the @pleach/core schema bundle.