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 result = await middleware.verifyAfterBatch(rows);
if (!result.chainValid) {
// failedIndex is the row offset where divergence was detected
console.error("chain divergence at index", result.failedIndex);
}The verifier delegates to complianceRuntime.verifyChain({ chatId }).
Returns { chainValid: boolean; failedIndex?: number }.
Soft-fail vs throw
The policy is explicit:
- Adapter / transport errors — soft-failed. A transient blip in
the substrate's verifier (network reset, Postgres pool exhausted,
Supabase rate limit) returns
{ chainValid: true }with no throw. The middleware MUST NOT cause a compliance gate to fail because the verifier was temporarily unreachable. - Substrate-detected integrity break — throws
ChainVerificationError. When the verifier returns{ valid: false, mismatchAtSequenceNumber: N }withN >= 0, that's a real tamper signal. The error carries the failing sequence number + the chat scope.
The runtime-error sentinel (mismatchAtSequenceNumber: -1) is
soft-failed — that's the substrate's own internal-error escape hatch.
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 { chainValid, failedIndex } = await middleware.verifyAfterBatch(rows);
if (!chainValid) {
throw new Error(`chain integrity break at index ${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 SKU for `@pleach/core` hosts that want to expose their tool registry to stdio MCP clients.
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.