Fingerprint
The cache-key tuple — what's in it, what's deliberately excluded, why the split matters for caching, replay, and audit.
Fingerprint is one concept in the safety & determinism thematic island — siblings of safety, scrubbers, fabrication detection, and 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.
import {
computeFingerprint,
buildMetadata,
canonicalize,
sha256,
isCacheEligible,
PLEACH_CORE_VERSION,
} from "@pleach/core/fingerprint";
import type { Fingerprint, FingerprintInput, FingerprintConfig } from "@pleach/core/fingerprint";@pleach/core/fingerprintSourcesrc/fingerprint/The key/metadata 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
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
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
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 themThe 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
Floats aren't a reliable cache key — two paths that compute
"0.7" through different arithmetic produce different bytes.
The fingerprint quantizes:
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
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
Two helpers exported for building related cache keys:
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
The eligibility predicate. A call is eligible iff it's deterministic — every other condition is a consumer-side concern layered on top.
import { isCacheEligible } from "@pleach/core/fingerprint";
const eligible = isCacheEligible({ temperature, seed });A call is eligible when either:
temperature === 0(greedy / argmax sampling — no stochasticity), orseedis set (!== undefinedand!== 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 runtimeModes — 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
The identity-like sibling of the fingerprint. Carries the fields the cache key deliberately excludes so the audit ledger can still join them.
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
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
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
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.
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
The fingerprint exists so the replay story works. The contract:
- Same fingerprint input → same fingerprint hash.
- Same fingerprint hash + same provider → same output bytes.
- Output bytes feed into channel reducers deterministically.
- 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). The
fingerprint is what keeps the chain honest.
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
Determinism
Why every primitive in `@pleach/core` is deterministic by contract — the replay story end-to-end, the invariants that hold it together, and the four ways it breaks.
Plugin contract
The HarnessPlugin extension surface — what plugins can do, what they can't, and the structural invariants the substrate enforces against them.