# Compliance · HashChainMiddleware (/docs/compliance/hashchain-middleware)



`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 [#the-factory]

```ts
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 [#beforewrite--decorate-each-event]

```ts
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 [#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 [#verifyafterbatch--substrate-integrity]

```ts
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 [#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 [#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 [#configuring-the-runtime]

`HashChainMiddleware` requires a `ComplianceRuntime` — typed against
`@pleach/compliance-contract` (the zero-dep cycle-break contract sub-SKU).
Construct the runtime separately:

```ts
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 [#end-to-end-example]

```ts
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 [#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 [#where-to-go-next]

<Cards>
  <Card title="GDPR right-to-erasure" href="/docs/compliance/gdpr-erasure" description="The companion helper for Article 17 erasure requests against an append-only hash chain." />

  <Card title="Hash chain" href="/docs/hash-chain" description="Substrate-level walkthrough of @pleach/core's chain primitives." />

  <Card title="Compliance" href="/docs/compliance" description="The full @pleach/compliance surface — redaction, scrubbers, audit query, PDF export." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="What the chain protects — the event-log ledger that the writer persists." />
</Cards>
