Append-only triggers
UPDATE / DELETE on audit_events raises. Even superusers can't modify history.
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.
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.
// @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 };
}UPDATE / DELETE on audit_events raises. Even superusers can't modify history.
Genesis derived from tenant id; chains are independent; cross-tenant reads blocked by RLS.
Auto-created on bootstrap. Old partitions can be detached for cold storage; the chain stays valid.
auditVerificationWorkflow on Temporal cron. Mismatches open severity-breach cases.
MinIO bucket with object-lock (compliance mode). Files keyed by content hash.
Regulator-ready bundles with Vault transit signatures and a verification script.
Cases reference audit events; closing a case adds evidence to the chain.
FeatureAI mutations are linked: the audit log shows the human who approved; metadata points back to the AI source.
FeatureGenerated PDFs are content-addressed in evidence; the chain references the address, not the bytes.
We will hand you a small simulated chain and the verifier script. Watch a tampered row fail in front of you.