pleach
Operate

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 adapterThis page (Audit ledger)
See every field on a single row + how to query it in SQLThe 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 proofHash chain
Sign a turn corpus + emit a regulator-shaped attestation bundleAttestation
Ship the OTel span side (latency, error correlation, parent-threading)Observability + OTel observability
Pipe scrub gates into the writer for HIPAA / GDPR / PCI-DSSScrubbers + 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.

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

  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

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>>;
}
  • 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

ImplementationUse case
MemoryProviderDecisionLedgerTests, dev, in-process aggregation
NoopProviderDecisionLedgerAudit 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

On this page