Feature · Hash-chained audit

Tamper-evident by construction. Verified hourly.

SHA-256 chains per tenant. BEFORE UPDATE and BEFORE DELETE triggers raise. Hourly Temporal cron walks every chain and opens a breach case if anything mismatches.

What it does

Make tampering structurally impossible.

The audit context owns one aggregate: AuditEvent. Every regulated action — client created, order approved, document signed, AI mutation committed, policy updated — emits one. The event has a ULID id, a tenant id, an occurred_at timestamp, the actor (a typed enum: user / system / workflow / AI), the dotted-name action, the resource type and id, a metadata payload, the previous record’s hash, the content hash, and an optional Vault transit signature.

The chain is built deterministically: contentHash = SHA256(canonicalJson(...)); chainInput = prevHash + ":" + contentHash; recordHash = SHA256(chainInput). The genesis hash for a tenant is SHA256("wealthos-genesis:" + tenantId). Canonicalization follows a simplified RFC 8785: keys sorted alphabetically, no insignificant whitespace, integers as integers, fractional zeros stripped, UTF-8.

The audit_events table is partitioned by month — partitions for the current month and the next 24 are pre-created on bootstrap. BEFORE UPDATE and BEFORE DELETE triggers raise audit_events is append-only in any case. There is no API endpoint that updates an event. The only way to “change” an event is to append another one referencing it.

Verification runs every hour as a Temporal cron workflow. It walks every tenant’s chain, recomputes hashes, compares to stored values, and opens a breach-severity ComplianceCase the moment anything mismatches. The verification is also exposed as a CLI: pnpm audit:verify [--tenant=<uuid>]. Exit code 1 on mismatch.

Construction

The chain, in code.

@wealthos/audit · construction
// @wealthos/audit · construction
import { createHash } from "node:crypto";

function canonicalJson(value: unknown): string {
  // RFC 8785-simplified: sort keys, no insignificant whitespace.
  if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
  if (value && typeof value === "object") {
    return "{" + Object.keys(value).sort().map(k =>
      JSON.stringify(k) + ":" + canonicalJson((value as any)[k])
    ).join(",") + "}";
  }
  return JSON.stringify(value);
}

export function genesisHash(tenantId: string): string {
  return createHash("sha256").update("wealthos-genesis:" + tenantId).digest("hex");
}

export function buildAuditEvent(prevHash: string, payload: AuditPayload) {
  const contentHash = createHash("sha256").update(canonicalJson(payload)).digest("hex");
  const chainInput = prevHash + ":" + contentHash;
  const recordHash = createHash("sha256").update(chainInput).digest("hex");
  return { ...payload, prevHash, contentHash, recordHash };
}
Audit chain diagram
Capabilities

Six things this context owns.

Append-only triggers

UPDATE / DELETE on audit_events raises. Even superusers can't modify history.

Per-tenant chain

Genesis derived from tenant id; chains are independent; cross-tenant reads blocked by RLS.

Monthly partitions

Auto-created on bootstrap. Old partitions can be detached for cold storage; the chain stays valid.

Hourly verification

auditVerificationWorkflow on Temporal cron. Mismatches open severity-breach cases.

Evidence store

MinIO bucket with object-lock (compliance mode). Files keyed by content hash.

Signed exports

Regulator-ready bundles with Vault transit signatures and a verification script.

Growth shape

How chains grow in practice.

Audit event growth curve
Audit explorer screen
Audit demo

Run our audit verifier on a sample chain.

We will hand you a small simulated chain and the verifier script. Watch a tampered row fail in front of you.