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).
@pleach/core/auditSourcesrc/audit/records/The five promoted records
| Record | Audit version | What it captures |
|---|---|---|
InterruptDecisionRecord | v9 | Interrupt verdict (approved / edited / rejected / timeout) with the resolution payload |
TokenCostRecord | v10 | Per-call token usage split by cache hit, cache miss, and reasoning tokens |
ToolSelectionTrace | v11 | Candidate tool set, eliminated tools with reasons, final pick |
PlanGenerationRecord | v12 | Plan produced for a turn — anchor, step list, revision history |
SynthesisQualityRecord | v13 | Synthesis-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 matchingToolSelectionTracetells 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
AuditableCall row
The full row shape and the stage-to-payload map.
Audit ledger
The persistence interface that writes typed records.
Tamper-evident hash chain
The chain that hashes typed records as part of each row.
Observability
Read-side OTel spans and lifecycle events that join to typed records on `turnId`.
Eval and replay
The primary consumer of typed records — replay asserts on payload narrowing.
Branding
White-label primitives — override the brand string, log prefixes, and error-message text the runtime emits so output reads as your product, not as Pleach. configureBranding / getBrandingConfig / resolveLogPrefix / resolveErrorMessage.
TurnOrchestrator (formerly OrchestratorClient)
Per-turn handle threaded through the stream body — six typed facets (config, history, context, tools, model, prompts) plus the graph snapshot.