Provenance

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.put is write-once: a second put to an existing key throws ArtifactExistsError, never overwrites. S3ArtifactStore, GcsArtifactStore, and R2ArtifactStore all bind the same port over a real Object-Lock/Object-Retention backend; LocalArtifactStore is a filesystem double for dev/CI that enforces write-once but not the time lock.
  • Append-only audit chain: AuditChainStore.append composes the kernel's canonicalize/chainEntry/anchorChain functions, writes to a table whose migration grants the app role SELECT + INSERT only, and mints a fresh WORM anchor after every entry. verify catches tamper, reorder, and truncation.
  • Locked-version table with a derived current: LockedVersionStore.insertVersion appends under an advisory lock with UNIQUE(account_id, supersedes_id); currentVersion derives the tip two independent ways and throws if they ever disagree.

Install

bun add @caisson/audit-worm

The 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 written

verify 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, GOVERNANCE mode by default. 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.
  • 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 (wx open 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"); // v2

provenance 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.