# Typed auditable-call records (/docs/typed-records)



Typed records are the payload shape inside the
[audit-ledger cluster](/docs/audit-ledger#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](/docs/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).

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

## The five promoted 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](/docs/auditable-call-row)
for the full stage-to-slot map.

## The additive-promotion contract [#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:

```typescript
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 [#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:

```typescript
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) [#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) [#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) [#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) [#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) [#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 [#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:

```typescript
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 [#analytics-integration]

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

```sql
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](/docs/query) for the full set of per-record rollups.

## What typed records do NOT replace [#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 [#where-to-go-next]

<Cards>
  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The full row shape and the stage-to-payload map." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The persistence interface that writes typed records." />

  <Card title="Tamper-evident hash chain" href="/docs/hash-chain" description="The chain that hashes typed records as part of each row." />

  <Card title="Observability" href="/docs/observability" description="Read-side OTel spans and lifecycle events that join to typed records on `turnId`." />

  <Card title="Eval and replay" href="/docs/eval-and-replay" description="The primary consumer of typed records — replay asserts on payload narrowing." />
</Cards>
