audit-worm
S3/GCS/R2 Object-Lock WORM storage plus an append-only SHA-256 audit chain and a derived-current locked-version table, evidence that cannot be altered before retention expires, and tampering that is provable.
@caisson/audit-worm is three composable layers over @caisson/kernel's pure integrity
algebra, each enforced by a different mechanism: a write-once ArtifactStore backed by
Object-Lock, an append-only AuditChainStore anchored into that store, and a
LockedVersionStore whose "current" version is derived, never stored. Every method is
tenant-scoped through withTenant: a forgotten filter still sees only the caller's rows.
What it does
- WORM evidence storage:
ArtifactStore.putis write-once: a secondputto an existing key throwsArtifactExistsError, never overwrites.S3ArtifactStore,GcsArtifactStore, andR2ArtifactStoreall bind the same port over a real Object-Lock/Object-Retention backend;LocalArtifactStoreis a filesystem double for dev/CI that enforces write-once but not the time lock. - Append-only audit chain:
AuditChainStore.appendcomposes the kernel'scanonicalize/chainEntry/anchorChainfunctions, writes to a table whose migration grants the app role SELECT + INSERT only, and mints a fresh WORM anchor after every entry.verifycatches tamper, reorder, and truncation. - Locked-version table with a derived current:
LockedVersionStore.insertVersionappends under an advisory lock withUNIQUE(account_id, supersedes_id);currentVersionderives the tip two independent ways and throws if they ever disagree.
Install
bun add @caisson/audit-wormThe guarantee
Compliance-mode Object-Lock means immutability is enforced by the storage layer, not by your code. The audit chain makes tampering detectable, verify the chain and a single edited or truncated entry surfaces with its sequence number:
const result = await chain.verify("tenant_4f2c");
// { valid: false, brokenAt: 1184 } — the entry at seq 1184 was edited after it was writtenverify treats the WORM store as the trusted length oracle: if an anchor exists for a
length beyond what the DB can currently produce, the tail was cut, even though the
surviving rows still hash together as a clean prefix.
Quickstart
import { AuditChainStore, LocalArtifactStore, retainUntilFrom } from "@caisson/audit-worm";
const store = new LocalArtifactStore("./worm-data"); // swap for S3ArtifactStore in prod
const chain = new AuditChainStore({ db, store });
// Append a privileged action to the SHA-256 chain — mints a fresh WORM anchor too.
const { entry, anchor } = await chain.append(accountId, {
kind: "invoice.export",
actor: "user_4f2c",
});
// Verify the chain is intact before you hand the log to an auditor.
const result = await chain.verify(accountId);
if (!result.valid) {
throw new Error(`audit chain broken at seq ${result.brokenAt}`);
}// Write evidence under a retention lock. retainUntilFrom enforces the 6-year HIPAA/SEC
// floor — a term below it throws rather than silently rounding up.
import { buildArtifactKey } from "@caisson/audit-worm";
const key = buildArtifactKey(accountId, "evidence", "soc2-access-review.pdf");
await store.put(key, pdfBytes, { retainUntil: retainUntilFrom(new Date(), 7) });// Escalate GOVERNANCE → COMPLIANCE and record the change as chain evidence in one call —
// an unrecorded retention change is treated as a FAILED escalation.
import {
escalateRetention,
irreversibleComplianceOptIn,
COMPLIANCE_ACKNOWLEDGEMENT,
} from "@caisson/audit-worm";
const optIn = irreversibleComplianceOptIn({
bucket: "caisson-worm-prod",
acknowledgement: COMPLIANCE_ACKNOWLEDGEMENT,
deployment: "production",
});
await escalateRetention({
store: s3Store,
chain,
accountId,
key,
retainUntil: retainUntilFrom(new Date(), 7),
compliance: { optIn },
});The ArtifactStore port
put/get/head/extendRetention: a minimal object-store contract every backend binds.
assertSafeKey enforces the {account_id}/… prefix and rejects every traversal vector
(empty/./.. segments, null bytes, absolute paths); extendRetention is strictly
monotonic and throws rather than shorten or clamp a lock. Three cloud backends ship today:
S3ArtifactStore: Object-Lock over@aws-sdk/client-s3,GOVERNANCEmode by default.COMPLIANCEmode (SEC 17a-4 grade, irreversible untilretain_until) requires a typedirreversibleComplianceOptIn()naming the exact bucket, and only builds underNODE_ENV === "production": never under a test runner.GcsArtifactStore: the same port over GCS Object Retention Lock.R2ArtifactStore: an S3-compatible data plane paired with Cloudflare's separate bucket-lock retention plane.LocalArtifactStore: a filesystem double for dev/CI. Write-once is real (wxopen flag, TOCTOU-safe); the retention time-lock is echoed back as metadata but never enforced. Not court-admissible, only the cloud backends are.
The S3 transport is injected as S3Sendable = Pick<S3Client, "send">, so CI binds a stub
and no live cloud call runs in tests.
Configuration surface
retainUntilFrom(now, years?) computes a calendar-correct retention date. MIN_RETENTION_YEARS
is 6 (HIPAA §164.316(b)(2) and SEC 17a-4 both floor there) and DEFAULT_RETENTION_YEARS is
7; a term below the floor throws rather than rounding up. AuditChainStore takes an
optional now clock (for deterministic tests) and retentionYears for its own anchor
objects. S3ArtifactStoreConfig takes the injected client, bucket, an optional mode
("GOVERNANCE" | "COMPLIANCE", default GOVERNANCE), a complianceOptIn required iff
mode === "COMPLIANCE", and an optional per-tenant SSE-KMS key id for encryption at rest.
Cloud backend construction
S3ArtifactStore takes new; R2ArtifactStore.create and GcsArtifactStore.create are async
factories that verify the bucket's lock capability before the store is usable:
each refuses to construct against a bucket with no enabled lock rule covering its key prefix:
import { R2ArtifactStore, createR2LockReader } from "@caisson/audit-worm";
const r2Store = await R2ArtifactStore.create({
client: r2Client, // an S3-compatible client pointed at R2's endpoint
bucket: "caisson-worm-prod",
lockReader: createR2LockReader({
accountId: cfAccountId, // the lock-rule read is Cloudflare's REST API, not the S3 data plane
bucket: "caisson-worm-prod",
apiToken: cfApiToken,
}),
});Locked versions
LockedVersionStore never stores a "current" flag, currentVersion derives the tip from a
no-successor SQL predicate cross-checked against the kernel's pure currentVersions model, and
throws if the two ever disagree instead of guessing:
import { LockedVersionStore } from "@caisson/audit-worm";
const versions = new LockedVersionStore({ db });
const v1 = await versions.insertVersion(accountId, {
artifactId: "policy-doc-4f2c",
provenance: { artifactHash: sha256Hex, lockedBy: userId, reason: "initial publish" },
});
// supersedesId must name the same lineage's current tip — a fork hits ConflictError, not a
// silent second "current".
const v2 = await versions.insertVersion(accountId, {
artifactId: "policy-doc-4f2c",
supersedesId: v1.id,
provenance: { artifactHash: newHash, lockedBy: userId, reason: "annual review" },
});
const current = await versions.currentVersion(accountId, "policy-doc-4f2c"); // v2provenance is unknown on the way in, provenanceSchema (artifactHash, lockedBy,
reason) Zod-parses it before the INSERT, so a malformed or extra field is rejected at the
boundary, never stored. chainFor walks a version's full supersede lineage; isCurrent checks
one id without loading the chain.
Composing with the base
AuditChainStore and LockedVersionStore both take a Transactor from @caisson/tenancy-rls
and run every operation through withTenant, so they sit directly on the base's RLS
boundary, the same tenant scoping every other Caisson package uses. audit-worm depends
down on @caisson/kernel (the pure chain/version algebra) and @caisson/tenancy-rls only;
it never depends up on an edition or bundle.
Not a compliance certification
audit-worm ships the technical control an auditor checks for, tamper-evident evidence and a hash-chained log of privileged actions. It does not make an organization compliant; that determination is your organization's and its auditor's to make.
audit-worm is a paid primitive: sold standalone, or composed at runtime as a real
workspace:* dependency inside the Compliance bundle alongside @caisson/field-crypto,
@caisson/retention-runner, and the alert pipeline.
signing-primitive
Per-tenant evidence signing, a detached Ed25519 signature over a canonical, chain-anchored manifest body, with an optional RFC-3161 trusted-timestamp countersignature and a fail-closed verify path.
field-crypto
Per-tenant field encryption with wrapped random DEKs or self-hosted HKDF derivation, behind a pluggable FieldKeyProvider KMS port.