# Fingerprint (/docs/fingerprint)



Fingerprint is one concept in the **safety & determinism**
[thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) —
siblings of [safety](/docs/safety), [scrubbers](/docs/scrubbers),
[fabrication detection](/docs/fabrication-detection), and
[determinism](/docs/determinism).

A fingerprint is the equivalence class for an LLM call. Two calls
with the same fingerprint are interchangeable: the cache returns
one for the other; replay reuses the recorded result; audit groups
them together.

`computeFingerprint` is a pure function. Same inputs in any order
produce the same fingerprint, byte-for-byte. That's what makes
deterministic replay possible and what lets the cache survive
across processes.

```typescript
import {
  computeFingerprint,
  buildMetadata,
  canonicalize,
  sha256,
  isCacheEligible,
  PLEACH_CORE_VERSION,
} from "@pleach/core/fingerprint";
import type { Fingerprint, FingerprintInput, FingerprintConfig } from "@pleach/core/fingerprint";
```

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

## The key/metadata split [#the-keymetadata-split]

The single most load-bearing decision in the fingerprint contract
is what goes into the cache key versus what stays as metadata.

### What's IN the key [#whats-in-the-key]

Anything that changes the model's output bytes or defines a
soundness boundary.

| Field                | Why it's in the key                                                                        |
| -------------------- | ------------------------------------------------------------------------------------------ |
| `model`              | Different models produce different outputs                                                 |
| `promptHash`         | sha256 of canonicalized `messages`                                                         |
| `toolsHash`          | sha256 of the tool catalog, sorted by `name`                                               |
| `systemPromptHash`   | sha256 of the composed system prompt (omitted when absent)                                 |
| `temperatureBucket`  | Quantized temperature — `Math.round(temp * 10) / 10` at default precision                  |
| `seed`               | When set, deterministic provider seed                                                      |
| `family`             | Tokenizer / prompt-cache key / tool dialect lock                                           |
| `callClass`          | Determines the downgrade ladder                                                            |
| `runtimeMode`        | Determinism contract (see below)                                                           |
| `tenantId`           | Soundness — never leak one tenant's cache to another                                       |
| `pleachVersion`      | Schema invalidation on substrate upgrades                                                  |
| `safetyPoliciesHash` | sha256 of the `"<id>@<version>"` list, sorted by id; omitted when zero policies are active |

### What's deliberately OUT of the key [#whats-deliberately-out-of-the-key]

Identity-like fields. Putting these in the key would make caching
nearly useless and break replay.

| Field                           | Why it's NOT in the key                                            |
| ------------------------------- | ------------------------------------------------------------------ |
| `chatId`                        | Two fresh chats with identical input should hit the same cache     |
| `sessionId`                     | Same call in different sessions should reuse                       |
| `messageId`                     | Re-emitting an idempotent message shouldn't fork the cache         |
| `userId` / `orgId`              | Cache by content, not identity (tenant isolation handles security) |
| `environmentId` / `workspaceId` | Soft scopes; not soundness boundaries                              |
| `evalRunId` / `replayOfEventId` | These are *reuse* keys, not invalidation keys                      |
| `recordedAt`                    | Wall-clock time would invalidate the cache on every read           |

These live on `FingerprintMetadata` instead — joinable to the
fingerprint at audit time, never part of the equivalence class.

If you put `sessionId` in the key, two identical calls in
different sessions miss the cache. If you leave `tenantId` out,
the cache leaks across tenants. The split is the load-bearing
decision.

## Computing a fingerprint [#computing-a-fingerprint]

```typescript
import { computeFingerprint } from "@pleach/core/fingerprint";

const fp = computeFingerprint({
  model:                 "claude-sonnet-4-5",
  family:                "anthropic",
  callClass:             "synthesize",
  runtimeMode:           "interactive",
  messages:              [{ role: "user", content: "Hello" }],
  systemPrompt:          composedSystemPrompt,
  tools:                 toolDescriptors,
  temperature:           0.7,
  seed:                  42,
  tenantId:              "org_abc",
  activeSafetyPolicies:  [{ id: "compliance.pii-redaction", version: "1.2.0" }],
});

// fp.promptHash / fp.toolsHash / fp.systemPromptHash — sha256 hex digests
// fp.safetyPoliciesHash — present iff activeSafetyPolicies is non-empty
// fp.temperatureBucket / fp.seed — present iff the call set them
```

The returned `Fingerprint` carries both the canonical hash (for
joins, dedup, cache lookup) and the typed component fields (for
debugging, dashboards, and audit queries).

### Temperature bucketing [#temperature-bucketing]

Floats aren't a reliable cache key — two paths that compute
"`0.7`" through different arithmetic produce different bytes.
The fingerprint quantizes:

```typescript
temperatureBucket = Math.round(temperature * 10 ** precision) / 10 ** precision;
```

Default precision is 1 (`0.7` → `0.7`). Override via
`FingerprintConfig.temperaturePrecision` when you need finer
buckets — higher precision = lower cache hit rate.

Bucketing is a fallback for ineligible-but-fingerprintable
calls. Callers that want strict-zero matching should set
`temperature: 0` and rely on `isCacheEligible()` (below).

### Safety policies are sorted [#safety-policies-are-sorted]

`activeSafetyPolicies` canonicalizes by sorting the
`<id>@<version>` tuples by id before hashing, then stores the
sha256 as `safetyPoliciesHash`. Two operators who enable the same
set in different orders produce the same key.

## `canonicalize` and `sha256` [#canonicalize-and-sha256]

Two helpers exported for building related cache keys:

```typescript
import { canonicalize, sha256 } from "@pleach/core/fingerprint";

const json = canonicalize({ b: 2, a: 1 });
// → '{"a":1,"b":2}' — keys sorted, whitespace stripped, no key-order ambiguity

const hash = sha256(json);
// → 64-char hex digest, platform-uniform (Node, browser, edge)
```

`canonicalize` is the canonical-JSON encoder. Use it when you're
hashing structured data for cross-process comparison — it's the
same routine the fingerprint uses internally, so your derived
keys join cleanly.

## `isCacheEligible` [#iscacheeligible]

The eligibility predicate. A call is eligible iff it's
deterministic — every other condition is a consumer-side concern
layered on top.

```typescript
import { isCacheEligible } from "@pleach/core/fingerprint";

const eligible = isCacheEligible({ temperature, seed });
```

A call is eligible when either:

* `temperature === 0` (greedy / argmax sampling — no stochasticity), or
* `seed` is set (`!== undefined` and `!== null`) — caller pinned the RNG.

Calls with `temperature > 0` and no seed sample from a distribution;
a cache hit would replay a stale sample in place of a fresh one.
The predicate is pure, has no side effects, and is equivalent across
all `runtimeMode`s — eligibility is a property of the call, not the
runtime.

Ineligible calls can still have a fingerprint computed (for audit /
debug / cost projection). The cache MUST NOT serve a hit or write an
entry under their key. Enforcement lives at the cache boundary, not
inside `computeFingerprint()`.

## `buildMetadata` [#buildmetadata]

The identity-like sibling of the fingerprint. Carries the fields
the cache key deliberately excludes so the audit ledger can still
join them.

```typescript
import { buildMetadata } from "@pleach/core/fingerprint";

const meta = buildMetadata({
  // buildMetadata takes the SAME FingerprintInput as computeFingerprint and
  // extracts only the identity-like fields; the equivalence-class fields are
  // required inputs.
  family:           "anthropic",
  model:            "claude-sonnet-4-5",
  callClass:        "synthesize",
  runtimeMode:      "interactive",
  messages:         [{ role: "user", content: "Hello" }],
  // Identity-like fields — the ones the cache key deliberately excludes.
  chatId:           "chat_abc",
  sessionId,
  messageId,
  userId:           "user_123",
  orgId:            "org_abc",
  environmentId:    "prod",
  evalRunId,
  replayOfEventId,
  recordedAt:       new Date().toISOString(),
});
```

Pass both fingerprint and metadata when writing a ledger row; the
fingerprint defines the equivalence class, the metadata says
which row this particular write is.

## `RuntimeMode` values [#runtimemode-values]

The fingerprint includes `runtimeMode`, so a turn recorded in
one mode never collides with the cache of another. The closed
union:

| Mode              | Determinism contract                                                                |
| ----------------- | ----------------------------------------------------------------------------------- |
| `interactive`     | User-facing streaming chat; latency-sensitive                                       |
| `headless-eval`   | Batch eval; seed-pinned at runtime construction                                     |
| `headless-replay` | Replay from recorded events — cache MUST hit; a miss throws `ReplayDivergenceError` |
| `headless-job`    | Scheduled cron / async callback                                                     |
| `coding-agent`    | Multi-synthesize per turn                                                           |

Cross-mode read direction is one-way:
`interactive → headless-eval → headless-replay`. Headless modes
never read `interactive` entries — an interactive call was not
produced under a determinism contract, so a headless consumer
that borrowed from it would lose its replay guarantee. The
direction is gated by `CacheReadPolicy` (`strict-mode` is the
v1 default; `cross-mode-readable` is v1.x).

## `ReplayDivergenceError` [#replaydivergenceerror]

```typescript
import { ReplayDivergenceError } from "@pleach/core/fingerprint";
```

Thrown by replay-mode lookups when the cache misses. Carries the
`Fingerprint` that failed to resolve. `headless-replay` is the
one mode where a miss is fatal — a replay session cannot fall
through to a live call without breaking the recorded-equivalence
contract.

## `PLEACH_CORE_VERSION` [#pleach_core_version]

The substrate version. Lives in the fingerprint key so a
substrate upgrade invalidates the cache automatically. If the
runtime composes prompts differently after a version bump (a
common reason for output drift), the cache miss is automatic
rather than silent corruption.

```typescript
import { PLEACH_CORE_VERSION } from "@pleach/core/fingerprint";

console.log(PLEACH_CORE_VERSION); // e.g. "1.1.0"
```

Override only in tests where you want to assert a specific
version behavior — production code should always read the
constant.

## Replay determinism [#replay-determinism]

The fingerprint exists so the replay story works. The contract:

1. Same fingerprint input → same fingerprint hash.
2. Same fingerprint hash + same provider → same output bytes.
3. Output bytes feed into channel reducers deterministically.
4. Channel reducers + scheduling are deterministic given the
   superstep state.

Therefore: a recorded turn replays byte-identical against the
same package version + the same input. `@pleach/eval@0.1.0`
and `@pleach/replay@0.1.0` are built around this property and
ship today (entry-point bodies real; later-slice methods throw
typed sentinels — see [Packages](/docs/packages)). The
fingerprint is what keeps the chain honest.

### Common ways the chain breaks [#common-ways-the-chain-breaks]

| What breaks it                                                 | How to fix                                             |
| -------------------------------------------------------------- | ------------------------------------------------------ |
| A static prompt contribution reads session state               | Move it to the runtime-aware hook                      |
| An async stream observer (forbidden but sometimes attempted)   | Make it sync; route fan-out through named-channel emit |
| Wall-clock read inside a reducer                               | Read once at turn start; pass as a channel write       |
| A custom channel that mutates `checkpoint()`/`restore()` state | Implement honestly — snapshot must round-trip          |

Each of these silently breaks replay. The fingerprint test
catches them: two runs of the "same" turn produce different
fingerprints when one of these slips in.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Determinism" href="/docs/determinism" description="The replay-determinism chain end-to-end; fingerprint is one of the contracts that hold it." />

  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The fingerprint appears on every audit row." />

  <Card title="Prompts" href="/docs/prompts" description="The static/runtime-aware split that keeps composed prompts fingerprint-safe." />

  <Card title="Testing" href="/docs/testing" description="Fingerprint-based golden tests." />
</Cards>
