# Audit ledger (/docs/audit-ledger)



This page is the audit-ledger surface — the write side. For the
read-side observability tooling (OTel spans, lineage graph,
Datadog and Honeycomb wiring), see [Observability](/docs/observability).

## How the audit cluster pages relate [#how-the-audit-cluster-pages-relate]

Six pages cover the audit substrate at different grains. They
overlap on purpose — each one is the right page for a different
question. Use this table to skip to the one you need:

| If you want to...                                                          | Read                                                                                  |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Understand the write interface + register an adapter                       | **This page** (Audit ledger)                                                          |
| See every field on a single row + how to query it in SQL                   | [The AuditableCall row](/docs/auditable-call-row)                                     |
| See the typed envelope every event flows through (the wire shape)          | [Event log](/docs/event-log)                                                          |
| Build read-side projections off the event log (consumer-side rollups)      | [Event log projections](/docs/event-log-projections)                                  |
| Verify the hash chain on recorded rows or generate a tamper-evidence proof | [Hash chain](/docs/hash-chain)                                                        |
| Sign a turn corpus + emit a regulator-shaped attestation bundle            | [Attestation](/docs/attestation)                                                      |
| Ship the OTel span side (latency, error correlation, parent-threading)     | [Observability](/docs/observability) + [OTel observability](/docs/otel-observability) |
| Pipe scrub gates into the writer for HIPAA / GDPR / PCI-DSS                | [Scrubbers](/docs/scrubbers) + [Compliance](/docs/compliance)                         |

The shape, in one paragraph: every LLM call goes through a
**seam** → produces a **stream event** → lands in the **event
log** as a row → that row is the **AuditableCall** → the
write-side stamps a **hash chain** entry → optional
**attestation** signs a corpus of rows → **projections** read
from the log to compute rollups → **scrubbers** run at write time
to redact PII before the row hits disk. Every other page below
zooms into one of these stages.

That's the lattice carrying weight: every addressable event
the agent produces — each LLM call, tool dispatch, subagent
spawn — lands in a row at the same grain, which is why cost,
compliance, and replay all read from one table instead of three
pipelines.

With an `AuditEmitter` registered, every LLM call writes one
`AuditableCall` row through a `ProviderDecisionLedger`. The row
carries `tenantId`, `turnId`, `toolName`, `subagentDepth`,
`parentTurnId`, `modelId`, `family`, and `tokenUsage` — that's the
variable surface finance, replay, and compliance read from.
`@pleach/core` ships the write interface plus two reference
implementations; the default emitter is a no-op, so the row only
lands once the host registers an emitter — which the quickstart and
any production host do. Consumer code registers adapters for
Supabase, IndexedDB, S3, or any other store.

`tenantId` is the opaque rollup axis. In a multi-tenant SaaS it's
your end customer; in an internal-use deployment of an Anthropic /
OpenAI Enterprise contract it's typically the employee, team, or
cost-center identifier you want to chargeback or audit against.
The schema, hash chain, and CI gates don't care which — the
column carries whichever rollup your finance and compliance teams
actually report on. See
[Migrating from Anthropic Enterprise](/docs/migrating-from-anthropic-enterprise)
or [Migrating from OpenAI Enterprise](/docs/migrating-from-openai-enterprise)
for the composition story with an existing vendor contract.

Three compliance plug-points (tamper evidence, PII redaction,
GDPR soft-delete) ride on top of the ledger. Each ships as a
reference-only no-op today. `@pleach/compliance@0.1.0`
ships the four-scrubber bundle (`SSN-US`, `Luhn`, `US-DL`,
`KeyedRegex`) — see [scrubbers](/docs/scrubbers). The hash-chain
verifier ships in `@pleach/replay@0.1.0` (`verifyChainForChat`,
`generateProof`); writer-side stamping lives in
`@pleach/core/eventLog` (`chainStep`, `computeRowHash`) behind
the `c9PhaseBEnabled` flag. Subject-key-derived redaction wiring
on `PIIRedactor` and `GDPRSoftDelete` continues to land through
the `HarnessPlugin` contract; the plug-point shapes in
`@pleach/core` are the stable contract. See
[compliance](/docs/compliance) for the package overview.

```typescript
import {
  MemoryProviderDecisionLedger,
  NoopProviderDecisionLedger,
  AUDIT_RECORD_VERSION_HISTORY,
  tamperEvidenceNoop,
  piiRedactorNoop,
  gdprSoftDeleteUnwired,
  NoopAuditEmitter,
} from "@pleach/core/audit";
import type {
  ProviderDecisionLedger,
  AuditableCallQuery,
  AuditableCall,
  PluginAuditPayload,
  TamperEvidence,
  PIIRedactor,
  GDPRSoftDelete,
  AuditDecisionInput,
  AuditEmitter,
} from "@pleach/core/audit";
```

See [AuditableCall row](/docs/auditable-call-row) for the row
shape itself; this page is about the persistence and the
plug-points.

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

## The audit-ledger cluster [#the-audit-ledger-cluster]

The ledger is one of three concepts paired with
[the AuditableCall row](/docs/auditable-call-row) (what gets
written) and [the hash chain](/docs/hash-chain) (after-the-fact
tamper detection). The row is the grain; the ledger is the write
path; the chain is the integrity layer. The full triplet framing
lives at
[Concept clusters → Audit-ledger](/docs/concept-clusters#audit-ledger-cluster);
the rest of this page is the deep dive on the ledger interface.

## `ProviderDecisionLedger` [#providerdecisionledger]

The write interface. One method, fire-and-forget.

```typescript
interface ProviderDecisionLedger {
  recordCall(call: AuditableCall): Promise<void>;
}
```

Two contract rules:

1. **Append-only.** No update or delete primitive. An audit row
   that needs to mutate is a wire-format break — bump
   `auditRecordVersion` instead.
2. **Idempotent on `(sessionId, turnId, stageId, seqWithinTurn)`.**
   Re-emitting the same coordinates is a no-op rather than a
   duplicate row. This matters because the seam call site wraps
   `recordCall` with `.catch(noop)` — retries on transient failure
   are safe.

Adapters that can batch SHOULD buffer at most 50 ms or 32 records
(whichever first) and flush unconditionally at end-of-turn.

## `AuditableCallQuery` [#auditablecallquery]

The read interface. Adapters that are write-only sinks (forwarding
to a SIEM, say) may omit it; adapters that consumers will query
(the in-app history UI, eval, SOC2 evidence exports) implement
both.

```typescript
interface AuditableCallQuery {
  getTurn(
    sessionId: string,
    turnId: string,
  ): Promise<ReadonlyArray<AuditableCall>>;

  getSession(
    sessionId: string,
    opts?: { readonly limit?: number; readonly before?: string },
  ): Promise<ReadonlyArray<AuditableCall>>;

  streamByTimeRange(opts: {
    readonly fromIso: string;
    readonly toIso: string;
    readonly chunkSize?: number;
  }): AsyncIterable<ReadonlyArray<AuditableCall>>;
}
```

* `getTurn` returns rows in `seqWithinTurn` ascending order. Empty
  array if the turn has no records — never throws on "not found."
* `getSession` returns newest-first, limit defaults to 100. `before`
  is a `recordId` (ULID) cursor — see `ulid` below for why ULID sort
  works without a separate timestamp index.
* `streamByTimeRange` yields chunks of at most `chunkSize` (default
  500\) records. Adapters that can't stream MAY emit a single chunk;
  consumers MUST treat the iterable as the boundary, not the chunk.

## Reference implementations [#reference-implementations]

| Implementation                 | Use case                                   |
| ------------------------------ | ------------------------------------------ |
| `MemoryProviderDecisionLedger` | Tests, dev, in-process aggregation         |
| `NoopProviderDecisionLedger`   | Audit isn't wired yet; calls write nowhere |

Both ship in `@pleach/core/audit`. Concrete persistence adapters
(Supabase, IndexedDB, S3) live in consumer code.

```typescript
import { MemoryProviderDecisionLedger } from "@pleach/core/audit";
import { SessionRuntime } from "@pleach/core";

const ledger = new MemoryProviderDecisionLedger();

// The ledger is bound to the runtime via the registry accessor
// (`setProviderDecisionLedgerFactory`, shown below) — it is NOT a
// `SessionRuntimeConfig` field. In tests you can also read the
// MemoryProviderDecisionLedger directly.
const runtime = new SessionRuntime({
  storage: memoryAdapter,
  userId: "test-user",
});

// drive a turn ...
const rows = await ledger.getSession(sessionId, { limit: 100 });
```

## Writing your own adapter [#writing-your-own-adapter]

A typical Supabase-backed adapter:

```typescript
// lib/audit/supabaseLedger.ts
import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit";
import type { SupabaseClient } from "@supabase/supabase-js";

export class SupabaseProviderDecisionLedger implements ProviderDecisionLedger {
  constructor(private client: SupabaseClient) {}

  async recordCall(call: AuditableCall): Promise<void> {
    const { error } = await this.client
      .from("harness_auditable_calls")
      .upsert(this.toRow(call), {
        onConflict: "session_id,turn_id,stage_id,seq_within_turn",
        ignoreDuplicates: true,
      });

    if (error) {
      console.warn(`[audit-ledger] insert failed: ${error.message}`);
      // Soft-fail. Never throw.
    }
  }

  private toRow(call: AuditableCall) {
    return {
      record_id:            call.recordId,
      audit_record_version: call.auditRecordVersion,
      session_id:           call.sessionId,
      turn_id:              call.turnId,
      stage_id:             call.stageId,
      seq_within_turn:      call.seqWithinTurn,
      // ... rest of the columns per the schema bundle
      payload:              call.decision,
    };
  }
}
```

Register the factory on the runtime substrate via
`setProviderDecisionLedgerFactory` (a soft-accessor the runtime
reads at construction time):

```typescript
import { setProviderDecisionLedgerFactory } from "@pleach/core/runtime";

setProviderDecisionLedgerFactory({
  fromSupabase: (client) => new SupabaseProviderDecisionLedger(client),
});
```

The factory is called once per runtime construction — the runtime
passes its host-supplied Supabase client to `fromSupabase` and
holds the returned ledger for the runtime's lifetime.

## `AUDIT_RECORD_VERSION_HISTORY` [#audit_record_version_history]

The wire-format version log. Consumers can render "what changed
in v7" at runtime by reading this constant:

```typescript
import { AUDIT_RECORD_VERSION_HISTORY } from "@pleach/core/audit";

for (const entry of AUDIT_RECORD_VERSION_HISTORY) {
  console.log(`v${entry.to} (${entry.landedAt}): ${entry.reason}`);
}
```

Bumps are gated by an upstream audit gate
(`audit:auditable-call-version`); consumer adapters check this
constant during migration to know which payload fields are
new in the version they're persisting.

## The three compliance plug-points [#the-three-compliance-plug-points]

The audit module ships three plug-point interfaces with
reference-only no-op defaults. [`@pleach/compliance@0.1.0`](/docs/compliance)
ships the four-scrubber bundle (`SSN-US`, `Luhn`, `US-DL`,
`KeyedRegex`). Hash-chain verification rides in
[`@pleach/replay@0.1.0`](/docs/replay). Subject-key-derived
`PIIRedactor`/`GDPRSoftDelete` wiring continues to be host-side
plug-points; the plug-point shapes below are the stable contract.

### `TamperEvidence` [#tamperevidence]

Hash-chain over previous records.

```typescript
interface TamperEvidence {
  link(record: AuditableCall, prevAnchor: string | null): string | null;
  verifySequence(
    records: ReadonlyArray<AuditableCall>,
    initialAnchor: string | null,
  ): string | null;
  readonly schemeId: string;
}

import { tamperEvidenceNoop } from "@pleach/core/audit";
// No-op default — every link returns null, verification always passes.
```

`link` is deterministic given the same input and returns the
anchor for `record` (`null` for the first record). `verifySequence`
returns `null` if the sequence is intact, or the `recordId` of the
first record that breaks the chain. Both are synchronous — verifiers
re-run `link` against stored records and compare anchors. `schemeId`
is recorded in `payload.tamper.scheme` (default `"none"`) so a
verifier knows which implementation to load.

**Live impl** at `@pleach/core/eventLog` (`chainStep`,
`computeRowHash`, `computeGenesisSeed`,
`PLEACH_C9_CANONICALIZATION_VERSION`) behind the
`c9PhaseBEnabled` flag — chains each row's hash to the previous
so any single-row mutation breaks the chain. Verification ships
in `@pleach/replay@0.1.0` as `verifyChainForChat` and
`generateProof`. The `TamperEvidence` plug-point above is the
audit-ledger-side handle; hosts that already route through
`@pleach/core/eventLog`'s chain leave it as the no-op default.

### `PIIRedactor` [#piiredactor]

Pattern-based redaction with an allowlist.

```typescript
interface PIIRedactor {
  redact(record: AuditableCall): AuditableCall;
  readonly policyId: string;
}

import { piiRedactorNoop } from "@pleach/core/audit";
// No-op default — identity passthrough.
```

`redact` is a pure function (same input → same output); adapters
call it exactly once per record before insert. `policyId` is
recorded in `payload.redaction.policy` (default `"none"`) so the
stored shape carries which policy generated it.

**Adjacent surface today.** `@pleach/compliance@0.1.0`
ships the row-level scrubber cohort (`SSN-US`, `Luhn`, `US-DL`,
`KeyedRegex`) on the event log via `contributeScrubbers`. The
ledger-side `PIIRedactor` plug-point above stays a host-injectable
no-op for cases where audit-ledger redaction differs from
event-log redaction; hosts that share the policy register the
same scrubber on both paths.

### `GDPRSoftDelete` [#gdprsoftdelete]

Subject-key-derived soft-delete.

```typescript
interface GDPRSoftDelete {
  softDelete(
    recordId: string,
    reason: RedactionReason,
    opts?: { readonly requestRef?: string | null },
  ): Promise<RedactedPayloadSentinel>;
}

import { gdprSoftDeleteUnwired } from "@pleach/core/audit";
// Throws on use — must be wired by the consumer.
```

`softDelete` replaces the named record's `payload` with a
`RedactedPayloadSentinel` and returns it for caller logging. It
MUST be idempotent — re-redacting an already-redacted record
returns the existing sentinel unchanged; adapters MUST NOT chain
redactions. The identity columns and call shape are untouched;
only the JSONB payload is replaced.

**Host-wired today.** The plug-point shape is the stable
contract; concrete wiring (tombstone table, subject-key
derivation, read short-circuit) is host-side. The audit rows
themselves stay (append-only contract); the tombstone gates
rendering. `@pleach/compliance@0.1.0` ships the
scrubber-side primitives now; subject-key soft-delete wiring is
on the compliance roadmap.

## `AuditEmitter` [#auditemitter]

The seam side of the audit surface — what the runtime calls when
a decision needs to be recorded. The emitter is what plugin code
sees; the ledger is what the emitter ultimately writes to.

```typescript
interface AuditEmitter {
  recordFamilyLockDecision(
    input: AuditDecisionInput,
    overrides?: { readonly provider?: ProviderFamily },
  ): void;
  recordFallbackStepDecision(input: AuditDecisionInput): void;
  recordCacheHitDecision(input: AuditDecisionInput): void;
  recordProviderCascadeDecision(input: AuditDecisionInput): void;
  recordToolFallbackStepDecision(input: AuditDecisionInput): void;
  recordInterruptDecisionDecision(input: AuditDecisionInput): void;
  recordTokenCostDecision(input: AuditDecisionInput): void;
  recordToolSelectionDecision(input: AuditDecisionInput): void;
  recordPlanGenerationDecision(input: AuditDecisionInput): void;
  recordSynthesisQualityDecision(input: AuditDecisionInput): void;
}

import { NoopAuditEmitter } from "@pleach/core/audit";
```

One method per typed payload slot — ten in all. The clustering is
structural: future per-kind dispatch (sampling, rate-limiting,
redaction) can land without changing emit sites. The default
implementation shares a single inner write across all ten — the
clusters exist for symmetry and forward-extensibility, not for
polymorphism today.

All methods are sync-fire-and-forget. Implementations MUST swallow
exceptions internally — a probe-side failure to record an
`AuditableCall` MUST NOT abort the turn. The signature deliberately
returns `void`, not `Promise<void>`, so emit sites can fire from
sync hot paths.

`AuditDecisionInput` takes a `turnKey` (the orchestrator's
messageId). The seam fills in `sessionId` from the active runtime,
`principal` from session config, `seqWithinTurn` from a per-turn
counter, `recordId` via `ulid()`, and `createdAt` via
`new Date().toISOString()`. Emit sites supply only what they
already know.

## Plugin-namespaced payloads [#plugin-namespaced-payloads]

`PluginAuditPayload` is the extension slot a plugin reaches for when
it has typed audit context to record but no existing core slot fits.
Per pleachfix #10, landed in `auditRecordVersion: 8`.

```typescript
import type { PluginAuditPayload } from "@pleach/core/audit";

interface PluginAuditPayload {
  readonly pluginId: string;   // e.g. "compliance-pharma"
  readonly subKind:  string;   // plugin-defined discriminator
  readonly data:     unknown;  // plugin-defined shape, plugin documents + casts
}
```

Entries land on `AuditableCall.pluginPayloads`. Core consumers
continue to switch exhaustively on the named optional slots
(`familyLock`, `tokenCost`, …); plugin consumers narrow on
`pluginId === "<their-id>"` then on `subKind` before reading
`data`. The core type-level shape of `data` is `unknown` — the
plugin owns its wire shape and version policy.

This is the contract that lets `@pleach/compliance` record
hash-chain evidence, a domain plugin record an extraction-quality
score, or an eval harness record a divergence reason — without any
of them coring out a new top-level field on every audit row.

## ULID record ids [#ulid-record-ids]

The `recordId` on every `AuditableCall` is a ULID — Crockford
Base-32, 26 chars, lex-sortable. The ULID encodes creation
timestamp in its high bits, so cursor pagination via
`recordId > cursor` works without an independent timestamp
index.

```typescript
import { ulid } from "@pleach/core/audit";

const id = ulid(); // → "01JC8XAEH..."
```

The substrate uses this helper for every record id. Don't roll
your own — the format guarantee is what makes lex-cursor reads
correct.

## Tamper-evident hash chain [#tamper-evident-hash-chain]

`harness_event_log` ships two columns — `prev_hash` and
`row_hash` — that link each row to its predecessor over a
canonical encoding. The chain detects silent backfill, reorder,
and removal after the fact; the verifier reports the first index
where the chain breaks. Schema landed; writer-side stamping is in
soak behind a flag. The column contract, the verifier signature,
the canonical encoding, and the back-compat path for pre-stamping
rows all live on [Hash chain](/docs/hash-chain).

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

<Cards>
  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The row shape this ledger persists — every field and typed payload slot." />

  <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="`prev_hash` + `row_hash` columns and the verification path." />

  <Card title="Typed records" href="/docs/typed-records" description="The five discriminated payload kinds on the row." />

  <Card title="Observability" href="/docs/observability" description="Read-side wiring — OTel spans, Datadog, Honeycomb, Prometheus — that wraps this ledger." />

  <Card title="Fingerprint" href="/docs/fingerprint" description="The fingerprint that appears on every audit row." />
</Cards>
