pleach
Plugins

Typed auditable-call records

Five audit-ledger records promoted from untyped JSON to typed, stage-correlated slots — Interrupt, TokenCost, ToolSelection, PlanGeneration, SynthesisQuality.

Typed records are the payload shape inside the audit-ledger cluster — the row carries them; the ProviderDecisionLedger writes them; the hash chain seals them. For read-side observability (OTel spans that join to these records on turnId, Datadog and Honeycomb wiring), see Observability.

Five AuditableCall records that used to persist as opaque JSON now ship as typed shapes carried in named, optional top-level slots on the row (interruptDecision, tokenCost, toolSelection, planGeneration, synthesisQuality). Consumers reading rows narrow by checking which slot is present — correlated with the stageId that fired the row; analytics queries stop reaching into untyped blobs; breaking changes get caught at compile time. The version log on the audit row tracks the promotion (v9 through v13, one additive bump per shape).

Subpath@pleach/core/auditSourcesrc/audit/records/

The five promoted records

RecordAudit versionWhat it captures
InterruptDecisionRecordv9Interrupt verdict (approved / edited / rejected / timeout) with the resolution payload
TokenCostRecordv10Per-call token usage split by cache hit, cache miss, and reasoning tokens
ToolSelectionTracev11Candidate tool set, eliminated tools with reasons, final pick
PlanGenerationRecordv12Plan produced for a turn — anchor, step list, revision history
SynthesisQualityRecordv13Synthesis-time quality probes (including imperativeFabricationDetected)

Each slot is stage-correlated and optional. A row MAY carry more than one — a tool-loop row can carry toolSelection (and, when an interrupt fired that iteration, interruptDecision); an anchor-plan row carries planGeneration. Consumers narrow on stageId or on slot presence. See AuditableCall row for the full stage-to-slot map.

The additive-promotion contract

Each version bump is additive. New fields appear; existing fields stay; renames and removals are wire-format breaks that need their own bump with explicit deprecation overlap. A consumer pinned to v9 keeps working when the substrate writes v13 — it sees the v13 row and narrows only the fields it knows about.

The AUDIT_RECORD_VERSION_HISTORY constant exposes the per-version delta so consumer code can render "what changed in v11" at runtime:

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

const v11 = AUDIT_RECORD_VERSION_HISTORY.find((e) => e.to === 11);
console.log(v11?.reason);
// → "ToolSelectionTrace promoted to a typed slot;
//    adds the `toolSelection` slot with candidate set,
//    eliminations, and final pick."

The audit:auditable-call-version CI gate enforces the additive rule. A PR that removes or renames a field on an existing record fails the gate unless it also bumps the version and ships a deprecation overlap.

Narrowing on the typed slot

Each typed record is an optional named slot on the row. Narrow by checking slot presence (correlated with the stageId that fired the row); TypeScript then narrows the slot to its concrete type:

import type { AuditableCall } from "@pleach/core/audit";

function summarize(call: AuditableCall): string {
  if (call.interruptDecision)
    return `interrupt:${call.interruptDecision.verdict}`;
  if (call.tokenCost)
    return `tokens:in=${call.tokenCost.inputTokens},out=${call.tokenCost.outputTokens}`;
  if (call.toolSelection)
    return `tool:${call.toolSelection.selectedTool}`;
  if (call.planGeneration)
    return `plan:${call.planGeneration.stepCount}`;
  if (call.synthesisQuality)
    return `synthesis:fabricationDetected=${call.synthesisQuality.imperativeFabricationDetected}`;
  return "no typed slot";
}

Adding a sixth record type adds a sixth optional slot; existing consumers keep compiling and simply don't read it until they add a branch — that's the additive, non-breaking guarantee the promotion buys.

InterruptDecisionRecord (v9)

Captures the outcome of a human-in-the-loop interrupt: the tool the interrupt gated, the operator who resolved it, the recorded verdict (approved / edited / rejected / timeout), and the resolution payload (argsBefore / argsAfter, plus the NL editPrompt when the verdict is edited).

What to derive from it. Replay tools assert that an interrupt resolved the same way on re-run — deterministic interrupt resolution is what makes replay reproducible across operator sessions. Analytics roll up interrupt rates per channel to spot channels whose guardrails fire too often (a signal the underlying tool schema needs tightening).

TokenCostRecord (v10)

Per-call token accounting, broken out by cache-read, cache-write, input, output, and reasoning tokens. Each field maps directly to the provider's billing line so a consumer can re-derive cost without re-querying the provider.

What to derive from it. Billing joins on recordId and sums tokens per tenantId and turnId. The cache-hit / cache-miss split is the cache-effectiveness signal — a tenant whose cache-read ratio drops over time has prompts whose stable prefix is fragmenting. Reasoning tokens are tracked separately because the unit cost is different.

ToolSelectionTrace (v11)

Records the tool-selection path: the candidate set the agent considered, every tool eliminated with the elimination reason, and the final pick. Fires on every tool-loop row that runs a tool-selection step.

What to derive from it. Debugging "why did the agent pick this tool" without re-running the turn — the trace is the audit trail. Build a per-tool elimination-reason histogram to find tools whose descriptions consistently lose to a sibling (usually a sign the description needs work).

PlanGenerationRecord (v12)

Carries the plan produced for a turn: the anchor that grounds the plan, the ordered step list, and the revision history if the plan was re-generated mid-turn.

What to derive from it. Auditing plan-revision rate per agent. Plans that revise more than once per turn are a model-drift signal — the model is generating plans it can't follow. The revision-history field lets you read what the original plan looked like and what the model swapped in.

SynthesisQualityRecord (v13)

Synthesis-time quality probes that fire on every synthesize row, including the imperativeFabricationDetected boolean alongside the standard synthesis-quality fields and any consumer-registered probes. Each probe is a typed sub-field on the record — adding a probe is itself an additive bump.

What to derive from it. Per-channel alerting on rising imperativeFabricationDetected rates — a canonical "the model got worse" signal. Long-running rollups also catch slow drift that single-turn inspection misses.

Why version bumps are additive

The contract is "additive only." A field removal, type narrowing, or rename is a wire-format break and needs a v-bump with a deprecation overlap (the old field stays for one version, marked deprecated; consumers migrate; the next bump removes it).

The audit:auditable-call-version CI gate enforces this. It diffs the wire shapes against the previous version and fails the build on any non-additive change that doesn't ship matching deprecation metadata.

Consumers reading the version log can confirm whether a bump touches a field they read:

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

const touched = AUDIT_RECORD_VERSION_HISTORY
  .filter((e) => e.to > pinnedVersion)
  .flatMap((e) => e.fields);

if (touched.includes("synthesisQuality.imperativeFabricationDetected")) {
  // re-derive the alert threshold against the new field
}

Analytics integration

Each record kind lends itself to a canonical SQL rollup. A synthesis-quality fabrication-detection rate by day:

SELECT
  date_trunc('day', created_at) AS day,
  tenant_id,
  count(*) AS synthesize_rows,
  count(*) FILTER (
    WHERE (payload->'synthesisQuality'->>'imperativeFabricationDetected')::boolean
  ) AS fabrication_detected
FROM harness_auditable_calls
WHERE payload->'synthesisQuality' IS NOT NULL
  AND created_at >= now() - interval '30 days'
GROUP BY 1, 2
ORDER BY 1 DESC, 2;

The exact JSONB path is a property of your persistence adapter — the in-memory shape carries synthesisQuality as a named top-level slot; a Postgres adapter that nests the typed slots under a payload JSONB column reaches it as payload->'synthesisQuality'. Narrowing on slot presence (... IS NOT NULL) is the discriminator; once the adapter projects the slot to its own column, the predicate hits an index. See Query patterns for the full set of per-record rollups.

What typed records do NOT replace

  • The lifecycle stream (runtime.on). Typed records are persisted state read after the turn finishes; lifecycle events are real-time signals the runtime emits as the turn runs. A consumer that needs to react during the turn subscribes to the stream; a consumer that needs to audit afterwards reads the records.
  • OTel spans. Spans carry timing and parent/child structure; records carry decision payload. The two surfaces join on turnId — a span tells you how long the tool-selection step took, and the matching ToolSelectionTrace tells you what it decided.
  • The fingerprint. The fingerprint identifies which substrate version wrote the row and what cache bucket the call hit. The record kind identifies what the row describes. Both appear on every row; neither replaces the other.

Where to go next

On this page