Skip to content
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 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 — ComplianceRuntime interface (cycle-break contract).

Where to go next