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:
- Extracting the hash computation into a shared helper (
computeJournalHash) used by both the poster and the verifier — guarantees identical serialisation. - Adding a server-side verifier service that streams posted entries in batches of 1000, recomputes each hash, and reports mismatches.
- Persisting every run in
integrity_run_logfor trend visibility. - Running a nightly cron at 02:00 (server local) that does an unfiltered full-chain verification.
- Updating the audit UI to call the backend and surface the latest scheduled-run banner.
Files
- Shared helper —
ethica-api/src/finance/integrity/journal-hash.ts(computeJournalHash) - Service —
ethica-api/src/finance/integrity/hash-chain-verifier.service.ts(verifyHashChain,runAndPersist,rebaselineHashChain) - Controller —
ethica-api/src/finance/integrity/hash-chain-verifier.controller.ts(endpoints +@Cron(EVERY_DAY_AT_2AM)) - Model —
ethica-api/src/models/integrity-run-log.model.ts - Frontend page —
ethica-erp/app/(auth)/u/finance/audit/page.tsx(latest run banner at lines 409–444) - Frontend hooks —
ethica-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? }> }
- Body:
GET /finance/integrity/runs?limit=— paginated historyGET /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):
- Hash recompute — feed
(reference, date, sourceModule, narration, lines, hashedAt, previousHash)throughcomputeJournalHash. Compare to storedcurrentHash. Mismatch →hashMismatch. - Chain linkage — verify
entry.previousHash === priorEntry.currentHash. Skipped on windowed runs (whenfromDate,toDate, orfundIdis set) because filtering removes the true predecessor; the link check is only meaningful on full-chain runs. Operators needing chain assurance must run unfiltered. - 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_breakreports at every filter boundary. We document the limitation rather than producing noisy output.
Tests
- Tampering with
narrationvia raw SQL on a posted entry is caught ashashMismatch(the test the client-side audit cannot pass). - Tampering with
previousHash(while keepingcurrentHashconsistent for the tampered input) is caught aschainBreakon full-chain runs. - A clean chain returns zero issues; cron persists a
passedlog. - Windowed run skips chain check but still catches per-entry hash and balance issues.