Audit chain + WORM
An audit log that proves it wasn't edited: hash-chained entries, a write-once anchor per append, and an S3 Object-Lock store underneath.
What it is
Audit Chain + WORM is Caisson's evidentiary primitive: an append-only SHA-256 hash chain (AuditChainStore) anchored to a write-once WORM object on every append, plus the S3 Object-Lock artifact store (S3ArtifactStore) it anchors into and an append-only locked-version table with a derived current. Three composable layers, each enforced by a different mechanism, over the kernel's pure chain algebra.
What ships in the module
Append-only hash chain, three independent locks
AuditChainStore.append composes the kernel's canonicalize/chainEntry/anchorChain functions, writes each entry to a table whose migration grants the app role SELECT + INSERT only (no UPDATE/DELETE), serializes appends per tenant under a pg_advisory_xact_lock, and mints a fresh WORM anchor after every entry; a UNIQUE(account_id, seq) constraint is the hard belt if two appends race.
Verify catches tamper, reorder, and truncation
AuditChainStore.verify recomputes the hash chain against the trusted WORM anchor and treats the anchor store as the length oracle: if an anchor exists for a length beyond what the DB can currently produce, the tail was cut, and verify fails even though the surviving prefix hashes clean on its own.
S3 Object-Lock backend, write-once by construction
S3ArtifactStore.put is a conditional IfNoneMatch: '*' PUT; S3 answers 412 on an existing key, which the store turns into ArtifactExistsError. GOVERNANCE mode is the default everywhere; COMPLIANCE mode (SEC-17a-4 grade, irreversible until retain_until) requires a typed irreversibleComplianceOptIn() naming the exact bucket and only builds under NODE_ENV==='production', never under a test runner.
Retention floor and monotonic escalation
retainUntilFrom enforces MIN_RETENTION_YEARS=6 (HIPAA §164.316(b)(2) and SEC 17a-4 both floor at six years) and defaults new locks to 7; a term below the floor throws rather than silently rounding up. extendRetention only accepts a strictly-later date, and escalateToCompliance hardens GOVERNANCE→COMPLIANCE through the same three-belt gate as a write-time COMPLIANCE store; no code path ever shortens a lock or de-escalates it.
Locked-version table with a derived current
LockedVersionStore never stores a 'current' flag: insertVersion appends under an advisory lock with a UNIQUE(account_id, supersedes_id) constraint (a fork hits the belt and rolls back), and currentVersions/currentVersion derive the tip two independent ways (a no-successor SQL predicate and the kernel's currentVersions() over the loaded set), throwing if the two ever disagree.
async verify(accountId: string): Promise<ChainVerification> {
return withTenant(this.db, accountId, async (tx) => {
const entries = await loadEntries(tx, accountId);
// Truncation guard (TM-I): the WORM store is the trusted length oracle. An anchor for a length
// past what the DB can now produce means the tail was dropped — invalid even if the surviving
// prefix is internally consistent (which, being a true prefix, it always is).
const beyond = await this.store.head(
anchorKey(accountId, entries.length + 1),
);
if (beyond !== null) {
return { valid: false, brokenAt: entries.length };
}
if (entries.length === 0) {
return { valid: true, brokenAt: null };
}
const anchorObj = await this.store.get(
anchorKey(accountId, entries.length),
);
const anchor = decodeAnchor(anchorObj.body);
return verifyChain(entries, anchor);
});
}- The truncation guard checks for a WORM anchor ONE PAST the DB's current length, that catches a cut tail even though the surviving rows still hash together as a clean prefix.
- entries.length === 0 short-circuits to a valid empty chain, a brand-new tenant never has to special-case verify.