Compliance · HashChainMiddleware
Consumer-facing wire-up between the host event-log writer and @pleach/core's hash-chain primitives. beforeWrite decorates each event; verifyAfterBatch checks substrate integrity.
HashChainMiddleware is the consumer-facing wire-up between the host's
EventLogWriter substrate and the hash-chain primitives that live in
@pleach/core's eventLog/hashChain.ts. It tracks the prior rowHash
per (tenantId, chatId) scope so each new event can stamp prevHash
against the genuine prior anchor without the host plumbing hash state
through its write path manually.
Shipped in an earlier release.
The factory
import { createHashChainMiddleware } from "@pleach/compliance/hashchain";
const middleware = createHashChainMiddleware({
complianceRuntime,
tenantId: "tenant-acme",
chatId: "chat-2026-06-15",
});The middleware is single-scope — one (tenantId, chatId) pair per
instance. Hosts that multiplex across chats construct one middleware
per chat (construction is one closure capture; trivial cost). This
single-scope shape keeps the chain anchor unambiguous: the closure-held
priorRowHash slot only ever advances along one chain.
beforeWrite — decorate each event
const decorated = await middleware.beforeWrite(event);
// decorated.rowHash + decorated.prevHash now stamped
await writer.write(decorated);beforeWrite is called BEFORE the host's EventLogWriter canonicalizes
- persists. It decorates each
EventLogInputwithrowHash+prevHashslots. The first call per scope uses the genesis seed (computeMiddlewareGenesisSeed(tenantId, chatId)) asprevHash; every subsequent call carries the prior row'srowHash.
Honest scope-limit — middleware vs writer canonicalization
The middleware's rowHash covers ONLY its local view: the event's
type + a sorted-keys canonicalization of payload. This is
INTENTIONALLY narrower than @pleach/core's
canonicalizeRowForChain walker, which covers the full 18-field
CanonicalRowFields shape the writer persists.
The middleware operates BEFORE persistence — substrate-controlled
fields (sequence_number, tenant_id, server-assigned timestamps)
are not yet assigned, so the middleware cannot reproduce the deep
canonical form. The pre-persistence anchor here gives the writer a
deterministic prevHash to thread into the row; the deep
post-persistence canonical rowHash is computed by the writer at
storage time.
In practice: both anchors verify the chain. The middleware-stamped anchor catches tamper at the BEFORE-write window; the writer's deeper anchor catches tamper at the AFTER-write window. A two-tier verifier checks both.
verifyAfterBatch — substrate integrity
const verdict = await middleware.verifyAfterBatch(rows);
if (!verdict.chainValid) {
// outcome says WHY: an integrity concern, or a verifier that
// could not run. failedIndex localizes a divergence when found.
console.error(verdict.outcome, verdict.failedIndex, verdict.detail);
}The verifier delegates to complianceRuntime.verifyChain({ chatId }).
Returns a HashChainVerifyVerdict:
{
chainValid: boolean;
outcome: "verified" | "nothing-to-verify" | "verification-unavailable";
verifiedRowCount?: number; // rows the substrate reported verified; absent = not reported
chainedRowCount: number; // rows in the supplied batch carrying a row_hash
failedIndex?: number; // present when a divergence was localized
detail?: string; // reason on "verification-unavailable"
}A boolean cannot carry three states. "verified" means the
substrate walked at least one chained row and every anchor checked
out. "nothing-to-verify" means the batch held no chained rows —
chainValid: true there is a statement about an empty population,
which is why the verdict carries chainedRowCount beside it.
"verification-unavailable" means the verifier could not run —
transport error, the runtime-error sentinel, or an unnarrowable
verdict shape — and reports chainValid: false: an ungraded
chain must never present as a graded one.
That last arm is a behavior change. Earlier cuts consumed the
runtime's fail-closed error sentinel
(mismatchAtSequenceNumber: -1) and soft-failed it to
{ chainValid: true } — inverting the runtime's own posture on
the most safety-critical signal in the package. Pair chainValid
with outcome when consuming; a gate that reads the boolean alone
cannot tell a verified chain from an empty batch.
What throws, what doesn't
A verification error still does not throw — a transport blip
must not crash the host's write path; it surfaces as
"verification-unavailable" and the host's compliance posture
decides. Throwing is reserved for a substrate-detected integrity
break (valid: false with a non-negative
mismatchAtSequenceNumber), which raises ChainVerificationError
carrying the failing sequence number and the chat scope.
Local pre-walk
Before delegating to the runtime, verifyAfterBatch performs a local
chain walk over the supplied batch — checking that each row's
prev_hash matches the prior row's row_hash. This catches tamper at
the row anchors themselves without round-tripping through the
substrate. A mismatch throws ChainVerificationError with
reason: "prev_hash_mismatch".
Configuring the runtime
HashChainMiddleware requires a ComplianceRuntime — typed against
@pleach/compliance-contract (the zero-dep cycle-break contract sub-SKU).
Construct the runtime separately:
import { createComplianceRuntime } from "@pleach/compliance";
import { createHashChainMiddleware } from "@pleach/compliance/hashchain";
const complianceRuntime = createComplianceRuntime({
tenantId: "tenant-acme",
});
const middleware = createHashChainMiddleware({
complianceRuntime,
tenantId,
chatId,
framework: "gdpr", // ComplianceProfile — diagnostic context
});The optional framework field on HashChainMiddlewareConfig is
diagnostic context — the middleware does not call attestRun itself
(that's an explicit host decision).
End-to-end example
import { Pool } from "pg";
import { createComplianceRuntime } from "@pleach/compliance";
import { createHashChainMiddleware } from "@pleach/compliance/hashchain";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const complianceRuntime = createComplianceRuntime({
tenantId: "tenant-acme",
});
async function logTurn(tenantId: string, chatId: string, events: EventLogInput[]) {
const middleware = createHashChainMiddleware({
complianceRuntime,
tenantId,
chatId,
});
// Stamp each event with chain anchors before persistence.
const stamped = [];
for (const event of events) {
stamped.push(await middleware.beforeWrite(event));
}
// Host writer persists the stamped events. The writer canonicalizes
// its own deeper rowHash at storage time; the middleware-stamped
// anchor stays on the row as a second-tier verification anchor.
await writer.writeBatch(stamped);
// After the batch lands, verify chain integrity.
const rows = await readRowsForChat(pool, tenantId, chatId);
const verdict = await middleware.verifyAfterBatch(rows);
if (verdict.outcome !== "verified") {
throw new Error(
`chain not verified (${verdict.outcome}) at index ${verdict.failedIndex}`,
);
}
}Cited source
packages/compliance/src/hashchain/HashChainMiddleware.ts— factory + contract.packages/compliance/src/hashchain/index.ts— barrel re-export.packages/compliance/test/hashChainMiddleware.smoke.test.mjs— surface regression-lock.packages/core/src/eventLog/hashChain.ts— substrate primitives (chainStep,computeGenesisSeed,verifyChainForChat).packages/compliance-contract/src/index.ts—ComplianceRuntimeinterface (cycle-break contract).
Where to go next
GDPR right-to-erasure
The companion helper for Article 17 erasure requests against an append-only hash chain.
Hash chain
Substrate-level walkthrough of @pleach/core's chain primitives.
Compliance
The full @pleach/compliance surface — redaction, scrubbers, audit query, PDF export.
Audit ledger
What the chain protects — the event-log ledger that the writer persists.
@pleach/mcp
The `@pleach/mcp` package — Model Context Protocol server for `@pleach/core` runtimes over stdio, SSE, Streamable HTTP, or WebSocket, with fail-closed multi-tenant routing.
Compliance · GDPR right-to-erasure
executeErasureRequest — the structural primitive for Article 17 erasure against an append-only hash chain. Reads the fundamental tension honestly; surfaces both approaches.