Tamper-evident hash chain
prev_hash + row_hash columns on the audit-ledger event log — what the chain proves, how rows get verified, and how the default-on writer-side stamping ships in the @pleach/core schema bundle.
The hash chain is one of three concepts in the audit-ledger cluster — the AuditableCall row is what it protects; the ProviderDecisionLedger is the write path; this page covers the tamper-evidence layer that links every persisted row to its predecessor. For read-side observability (OTel spans, lineage, Datadog wiring) see Observability.
The hash chain protects the event log against three after-the-fact
tampers: a silent backfill that inserts a row at an earlier index,
a reorder that swaps two adjacent rows, and a removal that drops a
row from the middle of the stream. Each of those mutations breaks
the linkage between a row's prev_hash and the previous row's
row_hash, and verification reports the first index where the
chain breaks.
The chain doesn't protect against a compromised writer mutating rows in real time — a writer that owns both the row contents and the hash stamp can produce a self-consistent chain on top of any state it likes. Real-time write integrity is a runtime-attestation problem and out of scope for this page.
@pleach/core/eventLogSourcesrc/eventLog/The two columns
The chain rides on two columns added to harness_event_log:
| Column | Type | Meaning |
|---|---|---|
prev_hash | BYTEA (nullable) | the previous row's row_hash, copied verbatim (not re-hashed) |
row_hash | BYTEA (nullable) | raw 32-byte sha256 of a canonical encoding of the current row, excluding row_hash itself |
Both columns store the raw 32-byte digest as BYTEA (not text) —
the verifier keeps storage compact and the walk O(rows); it exports
hex only for display (encode(row_hash, 'hex')). Both are nullable
for back-compat: rows written before the migration landed carry
NULL in both, and stamped rows written after carry both. The
migration shape:
ALTER TABLE harness_event_log
ADD COLUMN IF NOT EXISTS prev_hash BYTEA,
ADD COLUMN IF NOT EXISTS row_hash BYTEA;
CREATE INDEX IF NOT EXISTS harness_event_log_chat_row_hash_idx
ON harness_event_log (chat_id, sequence_number DESC)
INCLUDE (row_hash)
WHERE chat_id IS NOT NULL AND row_hash IS NOT NULL;The partial index keys the verification walk: a chat's stamped
slice is read in (chat_id, sequence_number) order without a
sequential scan over pre-hash rows. One caveat — sequence_number
is a per-chat write ordinal, not a uniqueness guarantee. Under
concurrent writers a slice can carry a repeated ordinal, so a
verifier should treat sequence_number as a hint and resolve ties
by a monotonic insertion identity (created_at, id); ordering the
walk on sequence_number alone can report false breaks when two
rows share an ordinal.
What "canonical encoding" means
Two writers that observed the same row state need to compute the
same row_hash, otherwise the chain isn't verifiable across writer
restarts or across writers running in parallel. The encoding is a
deterministic byte serialization of the row's columns in a fixed
order, with JSONB normalized to sorted-key form and timestamps
serialized as their ISO-8601 string.
The exact column order and the JSONB normalization rules live in
the source — see the linked src/event-log/ directory. Don't
reimplement the encoding from a guess; consume the helper the
writer uses so a future column addition propagates through both
the stamp and the verifier in one place.
Lifecycle status
Be explicit about where the chain sits today.
Today. The prev_hash and row_hash columns ship in the
@pleach/core schema bundle (003_harness_event_log.sql), so a
bare npx pleach init install has columns to stamp into — no
host-only migration required. Writer-side stamping is on by
default (EventLogWriter's c9PhaseBEnabled, a single-character
rollback if you need to disable it): the writer reads the most
recent row_hash for the (tenant_id, chat_id) slice, computes
row_hash over the canonical encoding of the row it's about to
insert, and writes both columns as part of the insert. Rows written
before the columns existed carry NULL in both and are skipped by
the verifier. In a reference deployment, every row over a rolling
24-hour window carried both columns.
Concurrency note. The per-chat sequence_number is assigned by
reading the current max and incrementing; parallel writers can
therefore collide on an ordinal (see the verification-walk caveat
above). The chain links themselves are unaffected — prev_hash
still copies the prior row_hash verbatim — but a verifier must
order by insertion identity, not sequence_number, to walk it.
Shipping. The verifier ships in code on
@pleach/core/eventLog: verifyChainForChat(...) walks the chain
and reports the first index where it breaks, and generateProof(...)
produces a portable ChainProofV1 over a window of the chain.
@pleach/replay consumes verifyChainForChat to gate deterministic
re-execution against an untampered log. Because the same
chainStep, computeRowHash, computeGenesisSeed,
canonicalizeRowForChain, and PLEACH_C9_CANONICALIZATION_VERSION
helpers the writer uses are exported from @pleach/core/eventLog,
verifier and writer share canonical encoding by construction. The
SQL pattern below remains the fallback for ad-hoc audits or hosts
that haven't wired the in-code verifier.
C9 probes — proof the writer is reaching prod
Two [UXParity:c9-hash-chain-*] probes are wired into
EventLogWriter and hashChain.ts. They activate the dormant
audit:c9-hash-chain-integrity soak ledger so the 3-batch clean
gate can become load-bearing for the verifier-CLI cutover. Both
probes fire whether or not stamping is enabled — a missing emission
means a misconfigured rollout, not a passing one.
[UXParity:c9-hash-chain-row-stamp] (PE-1)
Fires once per chat-bearing flush row in
EventLogWriter.flushBatchWithRetry. Two phases:
phase: "active"—c9PhaseBEnabled === true,chainStepstampsprev_hash+row_hash; the probe carries the first 16 hex chars of each.phase: "disabled"— operator opt-out path;prevHashPrefixandrowHashPrefixarenullso canvas-grep cohorts can split active-vs-disabled fire counts without rerunning the cohort.
Payload:
interface C9HashChainRowStampInput {
phase: "active" | "disabled";
chatId: string;
tenantId: string;
prevHashPrefix: string | null; // 16 hex chars when active; null when disabled
rowHashPrefix: string | null;
// emitted with a tsMs timestamp
}[UXParity:c9-hash-chain-verify] (PE-2)
Fires once per verifyChainForChat call at the function epilogue
— the verifier walks the iterator collecting counters, then emits
one structured line with the totals + verdict. Locked at every
call, no sampling.
Payload:
interface C9HashChainVerifyInput {
chatId: string;
tenantId: string;
chainValid: boolean;
rowCount: number; // total rows examined (legacy + non-legacy)
nonLegacyRowCount: number; // chain-participating subset
failedIndex?: number; // present iff chainValid: false
warnOnly: boolean;
}chainValid: false is the structured JSON complement to the
existing [UXParity:c9-chain-verify-warn-only] console.warn.
Audit gate clean condition
The audit:c9-hash-chain-integrity gate evaluates a batch as
clean when three conditions hold over a three-batch window:
c9-hash-chain-row-stampcount> 0(any write-shadow emission proves the probe is reaching production)- Every
c9-hash-chain-verifyemit carrieschainValid: true - Zero
[C9:legacy-prefix]boundary-disagreement diagnostics
A single failing batch doesn't fail the deploy — the three-batch aggregation tolerates a transient sink delay. The activation gate flipping green is what promotes the writer from shadow to enabled.
Pure hash module + verification
The chain's hashing and verification logic ships today as a pure
module — no substrate imports, no writer wiring, no database
access. It builds on top of node:crypto and nothing else, so
verifiers, tests, and external auditors can consume the same
canonicalization the writer will use without pulling in the rest
of the runtime.
import {
PLEACH_C9_CANONICALIZATION_VERSION, // "pleach.c9.v1"
computeGenesisSeed, // (tenantId, chatId) → Buffer
canonicalizeRowForChain, // (CanonicalRowFields) → Buffer
computeRowHash, // (prevHash, canonical) → Buffer
chainStep, // genesis-aware single advance
verifyChain, // walk + row-precise diagnostic
isLegacyRow, // null-rowHash detector
} from "@pleach/core/eventLog";The genesis seed is derived per (tenant_id, chat_id) and carries
the version prefix into the hash, so a chain rooted at one
(tenant, chat) pair can't be grafted onto another. verifyChain
walks a slice top-to-bottom and returns either { ok: true } or a
row-precise diagnostic — { ok: false, failedIndex, expected, actual, reason } — pinpointing the first index where the stored
chain diverges from a recomputed one. isLegacyRow separates
pre-stamping rows from chain breaks so the verifier can emit a
distinct legacy signal rather than reporting a false tamper.
The next phases — writer-side stamping wiring at insert time, and
the @pleach/replay verification CLI surface previewed below —
build on this module. The canonicalization contract lives here so
both directions read from a single source.
What gets hashed, what doesn't
The chain hashes the persisted shape of the row — the bytes that
actually live in harness_event_log. Scrubber-redacted payloads
hash to the redacted form, not the pre-redaction form. That's
deliberate: the chain protects against tampering with what was
written, not against losing information to redaction.
| Field | In the hash? | Why |
|---|---|---|
record_id, session_id, event_type | Yes | Identity columns — load-bearing |
payload (post-scrubber) | Yes | Persisted shape only |
payload (pre-scrubber) | No | Never persisted |
sequence_number | Yes | Ordering signal |
prev_hash | Yes | Locks the chain back-link |
row_hash | No | Hashing its own field would be circular |
See Scrubbers for the redaction layer that shapes the payload before it reaches the writer.
Back-compat for pre-hash rows
A consumer that started its chain mid-history doesn't need to
backfill old rows. Verification skips any row whose row_hash is
NULL and resumes at the next stamped row. The first stamped row
in a tenant's history is the chain root for that tenant; everything
before it is unverified by construction.
In practice: a tenant who upgraded after the stamping flag flipped has a verifiable chain from the upgrade row forward. Older rows remain queryable; they just aren't covered by the chain.
Verification (preview)
When the verification CLI ships in @pleach/replay, the surface
will look roughly like:
npx pleach-replay verify-chain \
--session <session-id> \
--from <record-id>
# → ok through 12,847 rows
# → break at record_id 01JCXY... (prev_hash mismatch)Until then, verification is a recursive CTE that walks the chain
and reports the first row where prev_hash doesn't match the
previous row_hash:
WITH RECURSIVE chain AS (
SELECT
record_id,
prev_hash,
row_hash,
1 AS idx
FROM harness_event_log
WHERE session_id = $1
AND row_hash IS NOT NULL
ORDER BY record_id ASC
LIMIT 1
),
walked AS (
SELECT c.record_id, c.prev_hash, c.row_hash, c.idx
FROM chain c
UNION ALL
SELECT
e.record_id,
e.prev_hash,
e.row_hash,
w.idx + 1
FROM walked w
JOIN harness_event_log e
ON e.session_id = $1
AND e.record_id > w.record_id
AND e.row_hash IS NOT NULL
AND e.prev_hash = w.row_hash
ORDER BY e.record_id ASC
LIMIT 1
)
SELECT idx, record_id FROM walked ORDER BY idx DESC LIMIT 1;The last idx returned is the chain length. If it's shorter than
the count of stamped rows for the session, the chain breaks at the
next record_id after the returned one. The CLI will do this walk
plus a contents re-hash (recomputing row_hash from the row's
columns and comparing it to the stored value) — the SQL pattern
catches link breaks but not contents tampering on a single row.
Relationship to @pleach/replay
Replay reads the chain to assert deterministic re-execution against an untampered event log. A replay pass that walks a slice whose chain doesn't verify can't claim its result reproduces the original session — the inputs to the replay aren't trusted. The verification step is what lets replay diff results stand as evidence.
See Eval and replay for the replay surface itself.
What the hash chain doesn't replace
The chain is an after-the-fact tamper detector. It's not access control. Specifically, it does not replace:
- RLS. Row-level security gates who can read or write rows. The chain runs over whatever rows actually got written; it doesn't decide who got to write them.
- Auth. A signed JWT proves the requester is who they say they are. The chain says nothing about identity — it says only that the row sequence hasn't been mutated since it was written.
- In-flight encryption. TLS protects bytes between the writer and the database. The chain protects bytes after they land.
Layer the chain on top of those controls, not in place of them.
Where to go next
Audit ledger
The ProviderDecisionLedger and the three compliance plug-points the chain sits alongside.
AuditableCall row
The typed per-call row whose persistence shape the chain protects.
Typed records
The discriminated payload kinds the chain hashes as part of each row.
Observability
Read-side wiring — OTel, Datadog, Honeycomb — that reads the rows this chain protects.
Eval and replay
The replay surface that reads the chain to assert deterministic re-execution.
The AuditableCall row
The typed, append-only row shape that every LLM invocation writes to the audit ledger — identity, principal, call, decision, outcome, and typed payload slots.
Attestation
Ed25519-signed envelopes over audit-ledger slices — a pluggable key-store substrate that sits on top of the canonical row hash and turns it into portable, third-party-verifiable evidence.