Audit ledger
The ProviderDecisionLedger write interface, the AuditableCall row it persists, the three compliance plug-points, and how to wire your own adapter.
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.
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 |
| See the typed envelope every event flows through (the wire shape) | Event log |
| Build read-side projections off the event log (consumer-side rollups) | Event log projections |
| Verify the hash chain on recorded rows or generate a tamper-evidence proof | Hash chain |
| Sign a turn corpus + emit a regulator-shaped attestation bundle | Attestation |
| Ship the OTel span side (latency, error correlation, parent-threading) | Observability + OTel observability |
| Pipe scrub gates into the writer for HIPAA / GDPR / PCI-DSS | Scrubbers + 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
or 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. 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 for the package overview.
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 for the row shape itself; this page is about the persistence and the plug-points.
@pleach/core/auditSourcesrc/audit/The audit-ledger cluster
The ledger is one of three concepts paired with the AuditableCall row (what gets written) and the 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; the rest of this page is the deep dive on the ledger interface.
ProviderDecisionLedger
The write interface. One method, fire-and-forget.
interface ProviderDecisionLedger {
recordCall(call: AuditableCall): Promise<void>;
}Two contract rules:
- Append-only. No update or delete primitive. An audit row
that needs to mutate is a wire-format break — bump
auditRecordVersioninstead. - 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 wrapsrecordCallwith.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
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.
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>>;
}getTurnreturns rows inseqWithinTurnascending order. Empty array if the turn has no records — never throws on "not found."getSessionreturns newest-first, limit defaults to 100.beforeis arecordId(ULID) cursor — seeulidbelow for why ULID sort works without a separate timestamp index.streamByTimeRangeyields chunks of at mostchunkSize(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
| 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.
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
A typical Supabase-backed adapter:
// 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):
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
The wire-format version log. Consumers can render "what changed in v7" at runtime by reading this constant:
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 audit module ships three plug-point interfaces with
reference-only no-op defaults. @pleach/compliance@0.1.0
ships the four-scrubber bundle (SSN-US, Luhn, US-DL,
KeyedRegex). Hash-chain verification rides in
@pleach/replay@0.1.0. Subject-key-derived
PIIRedactor/GDPRSoftDelete wiring continues to be host-side
plug-points; the plug-point shapes below are the stable contract.
TamperEvidence
Hash-chain over previous records.
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
Pattern-based redaction with an allowlist.
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
Subject-key-derived soft-delete.
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
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.
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
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.
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
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.
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
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.
Where to go next
AuditableCall row
The row shape this ledger persists — every field and typed payload slot.
Tamper-evident hash chain
`prev_hash` + `row_hash` columns and the verification path.
Typed records
The five discriminated payload kinds on the row.
Observability
Read-side wiring — OTel spans, Datadog, Honeycomb, Prometheus — that wraps this ledger.
Fingerprint
The fingerprint that appears on every audit row.
What Pleach writes to your database
Every table the runtime and its plugins write — what's on by default, what you opt into, and what each row carries. The destination is your database, not ours.
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.