pleach
Operate

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 carries pluginId, subKind, and an opaque data payload 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.

Subpath@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.

SlotWire shapeWhere it fires
familyLockFamilyLockPayloadFamily-lock resolution boundary; carries resolverPath, emitSite, requestedFamily, familyMismatch, byokActive, byokKeyId, resolvedViaAlias, legacyDefaultModel
fallbackStepFallbackStepRecordPost-attempt of every fallback rung; carries originalFamily/originalModel/originalCallClass, attemptFamily/attemptModel/attemptCallClass, attemptIndex, reason (FallbackStepReason), inFamily, originalLadderStep, ladderStep, ladderRegression
providerCascadeProviderCascadeRecordGraph 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
cacheBreakpointCacheBreakpointLogProvider response boundary; carries fingerprintComposite, cacheReadTokens, cacheCreationTokens, inputTokens, hitPct
toolFallbackStepToolFallbackStepRecordRuntime tool-cascade pivot or class-exhaustion; carries originalTool, cascadedTo, capabilityId, failureClass (ToolFallbackFailureClass), attemptIndex, triedToolCount, inClass, cascadeApplied, optional triggerSite
tokenCostTokenCostRecordToken / usage accounting — populated with the real input/output token counts the provider reported
synthesisQualitySynthesisQualityRecordsynthesize rows only
toolSelectionToolSelectionTracetool-loop rows only
planGenerationPlanGenerationRecordanchor-plan rows only
interruptDecisionInterruptDecisionRecordWhen a human-in-the-loop interrupt fires
pluginPayloadsreadonly 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

  • sessionId joins to the session record.
  • turnId aggregates 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 on recordCall.

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

On this page