pleach
Compliance

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 EventLogInput with rowHash + prevHash slots. The first call per scope uses the genesis seed (computeMiddlewareGenesisSeed(tenantId, chatId)) as prevHash; every subsequent call carries the prior row's rowHash.

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 } with N >= 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.tsComplianceRuntime interface (cycle-break contract).

Where to go next

On this page