# The AuditableCall row (/docs/auditable-call-row)



This page is the audit-ledger row shape. For read-side observability
(OTel spans, lineage, Datadog patterns), see
[Observability](/docs/observability).

The row is one of three concepts in the audit-ledger cluster — the
row (this page), the [ProviderDecisionLedger](/docs/audit-ledger)
write interface, and the [hash chain](/docs/hash-chain) that
protects against after-the-fact tampers. See
[Audit ledger → the audit-ledger cluster](/docs/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.

<Callout type="warn" title="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`](/docs/ownership#pleachcore--the-substrate)
  for the broader locked-vs-configurable map.
</Callout>

`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](/docs/migrating-from-anthropic-enterprise)
and [Migrating from OpenAI Enterprise](/docs/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.

<SourceMeta subpath="@pleach/core/audit" source="{ label: &#x22;src/audit/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/audit&#x22; }" />

## Row shape [#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 [#identity]

<TypeTable
  type="{
  auditRecordVersion: { type: 'literal', description: 'Current wire-format version (19).' },
  recordId:           { type: 'ULID', description: 'Crockford-Base32, 26 chars, monotonic within ms.' },
  sessionId:          { type: 'string', description: 'Joins to the session record.' },
  turnId:             { type: 'string', description: 'Stable across retries within a turn.' },
  stageId:            { type: 'enum', description: 'One of anchor-plan / tool-loop / synthesize / post-turn.' },
  seqWithinTurn:      { type: 'number', description: 'Monotonic per turn, starts at 0.' },
  createdAt:          { type: 'ISO-8601', description: 'UTC timestamp.' },
}"
/>

### Principal [#principal]

`AuditableCallPrincipal` is replicated onto every row so SOC2 user-action
queries don't need a session join.

<TypeTable
  type="{
  userId:            { type: 'string | null', description: 'Null for anonymous / guest.' },
  actorKind:         { type: 'enum', description: 'user / guest / scheduled / system.' },
  sessionAuthMethod: { type: 'enum', description: 'clerk / api-key / guest-token / internal.' },
}"
/>

### Call shape [#call-shape]

`AuditableCallShape` is post-routing — what actually ran, not what was
requested.

<TypeTable
  type="{
  callClass: { type: 'CallClass', description: 'Locked at session start.' },
  provider:  { type: 'ProviderFamily', description: 'The resolved provider family, populated with the real value the resolver picked.' },
  model:     { type: 'string', description: 'The resolved model id — a concrete model name, not a role/decision label. Falls back to the sentinel string &#x22;unresolved&#x22; when no model resolved (suppressed / empty / error terminal).' },
  transport: { type: 'enum', description: 'native / openrouter / byok-native / byok-openrouter.' },
}"
/>

### Decision [#decision]

`AuditableCallDecision` names the *policy* that picked the rung.

<TypeTable
  type="{
  requestedModel: { type: 'string | null', description: 'What the caller asked for.' },
  selectedReason: { type: 'enum', description: 'family-lock / fallback-rung / matrix-pick / user-explicit.' },
  rungOrdinal:    { type: 'number', description: '0 = first-pick.' },
  fallbackChain:  { type: 'readonly string[]', description: 'Prefix of models attempted before this one.' },
}"
/>

### Outcome [#outcome]

`AuditableCallOutcome` carries the provider's coarse result.

<TypeTable
  type="{
  status:       { type: 'AuditableCallStatus', description: 'ok / provider-error / guard-rejected / timeout / user-aborted.' },
  latencyMs:    { type: 'number', description: 'Provider-side latency.' },
  finishReason: { type: 'string | null', description: 'Unmodified provider string (e.g. &#x22;stop&#x22;, &#x22;tool_use&#x22;, &#x22;length&#x22;).' },
  httpStatus:   { type: 'number | null', description: 'Provider HTTP status when applicable.' },
  why:          { type: 'ProviderDegradationWhy | null', description: 'Provider-degradation diagnostics (&#x22;the WHY&#x22;) for a non-clean / empty outcome — raw native finish reason, serving provider, gateway attempt, optional post-hoc stats. Null/absent on a clean success (additive V18→V19).' },
}"
/>

## Typed payload slots [#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 [#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 [#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.

```typescript
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 [#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 [#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 [#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`](/docs/typed-records).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Audit ledger" href="/docs/audit-ledger" description="The ProviderDecisionLedger interface and the persistence adapters that write these rows." />

  <Card title="Typed records" href="/docs/typed-records" description="The five typed record slots and the analytics each one unlocks." />

  <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="`prev_hash` + `row_hash` — what protects this row's persistence." />

  <Card title="Observability" href="/docs/observability" description="Read-side wiring — OTel spans, Datadog, Honeycomb — that joins to rows on `turnId`." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="The event-type allowlist and the four bundled scrubbers that gate harness_event_log writes." />
</Cards>
