Hash chain verifier — server-side recompute + nightly run log (Phase 0 Task 0.4)

Status: ✅ Complete

Plan reference: Task 0.4 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)

What it does

Every posted journal entry carries a currentHash (SHA-256 over its content + the previous entry's hash). The original audit UI under Finance → Ledger audit already checked chain linkage (each previousHash matches the prior currentHash) and balance (DR = CR), but it disclaimed that it did not recompute currentHash. That meant an attacker could mutate a posted entry's narration, recompute its hash, and the client-side audit would still pass.

Phase 0 fixed that by:

  1. Extracting the hash computation into a shared helper (computeJournalHash) used by both the poster and the verifier — guarantees identical serialisation.
  2. Adding a server-side verifier service that streams posted entries in batches of 1000, recomputes each hash, and reports mismatches.
  3. Persisting every run in integrity_run_log for trend visibility.
  4. Running a nightly cron at 02:00 (server local) that does an unfiltered full-chain verification.
  5. Updating the audit UI to call the backend and surface the latest scheduled-run banner.

Files

  • Shared helperethica-api/src/finance/integrity/journal-hash.ts (computeJournalHash)
  • Serviceethica-api/src/finance/integrity/hash-chain-verifier.service.ts (verifyHashChain, runAndPersist, rebaselineHashChain)
  • Controllerethica-api/src/finance/integrity/hash-chain-verifier.controller.ts (endpoints + @Cron(EVERY_DAY_AT_2AM))
  • Modelethica-api/src/models/integrity-run-log.model.ts
  • Frontend pageethica-erp/app/(auth)/u/finance/audit/page.tsx (latest run banner at lines 409–444)
  • Frontend hooksethica-erp/services/finance.ts:694 (useVerifyHashChain), :712 (useLatestIntegrityRun)

Endpoint contract

  • POST /finance/integrity/verify-chain (admin + FINANCE_AUDIT)
    • Body: { fromDate?: string, toDate?: string, fundId?: string }
    • Synchronous; runs the verification, persists an IntegrityRunLog, returns the full result
    • Response: { verified, hashMismatches, chainBreaks, balanceErrors, legacyUnhashedTimestamps, broken: Array<{ id, reference, reason, expectedHash?, actualHash? }> }
  • GET /finance/integrity/runs?limit= — paginated history
  • GET /finance/integrity/runs/latest — used by audit UI banner

How verifyHashChain works

For each posted entry in (createdAt ASC, id ASC) order (lines re-ordered id ASC to match the poster's serialisation):

  1. Hash recompute — feed (reference, date, sourceModule, narration, lines, hashedAt, previousHash) through computeJournalHash. Compare to stored currentHash. Mismatch → hashMismatch.
  2. Chain linkage — verify entry.previousHash === priorEntry.currentHash. Skipped on windowed runs (when fromDate, toDate, or fundId is set) because filtering removes the true predecessor; the link check is only meaningful on full-chain runs. Operators needing chain assurance must run unfiltered.
  3. Balance — total debits − total credits within ±0.01. Mismatch → balanceError.

Legacy entries (hashedAt: null, posted before the hash column existed) are surfaced under a distinct legacy_unhashed_timestamp category instead of being reported as tampering — we can't recompute their hash because the original Date is lost.

Streaming batch size: 1000 (BATCH_SIZE in the service). The pre-Phase-0 client-side check was capped at 9999 in total and would have timed out on real fund volumes.

IntegrityRunLog

Fields per the plan spec: id, runAt, runType ('scheduled' | 'manual'), fromDate, toDate, fundId, totalEntries, hashMismatches, chainBreaks, balanceErrors, status ('passed' | 'failed' | 'errored'), durationMs, triggeredById, broken (JSONB sample).

status is derived: passed if all four counters are zero, failed if any are non-zero, errored if the run threw.

The nightly cron

@Cron(CronExpression.EVERY_DAY_AT_2AM) in the controller. Catches and logs exceptions (so the scheduler doesn't crash). Runs unfiltered — chain linkage check is in effect.

There is no notification path wired in yet because the existing notification module requires a sentById (no system-actor convention). The plan called this out: log at error level for now; a notification hookup will land when a system-actor convention is added.

Design rationale

  • Why one shared helper? Hash recompute is only trustworthy if the verifier serialises exactly like the poster. Two separate implementations drift; one helper cannot.
  • Why synchronous endpoint, not background job? Operators trigger audits when they need an answer now. A full-chain run on ~50k entries takes seconds.
  • Why batch of 1000? Large enough to avoid roundtrip overhead, small enough to bound memory.
  • Why drop chain check on windowed runs? Filtering hides predecessors and would produce false chain_break reports at every filter boundary. We document the limitation rather than producing noisy output.

Tests

  • Tampering with narration via raw SQL on a posted entry is caught as hashMismatch (the test the client-side audit cannot pass).
  • Tampering with previousHash (while keeping currentHash consistent for the tampered input) is caught as chainBreak on full-chain runs.
  • A clean chain returns zero issues; cron persists a passed log.
  • Windowed run skips chain check but still catches per-entry hash and balance issues.