audit-chain
GitHub: Stackbilt-dev/audit-chain · Apache-2.0
Part of the Stackbilt ecosystem. Pairs with @stackbilt/evidence-core to make E-E-A-T decisions tamper-provable, and with worker-observability for complementary monitoring. Used in production in the Stackbilder platform Content Provenance API — see Security for the full architecture.
Tamper-evident audit trail for Cloudflare Workers in under 200 lines of core logic. SHA-256 hash chaining with R2 immutability and D1 indexing. Zero production dependencies — uses only the Web Crypto API and Cloudflare bindings.
How it works
Every record includes a SHA-256 hash computed from the previous record’s hash plus the current record’s content:
Record 1 Record 2 Record 3
+-----------------+ +-----------------+ +-----------------+
| prev: GENESIS | | prev: hash_1 | | prev: hash_2 |
| data: {...} |---->| data: {...} |---->| data: {...} |
| hash: hash_1 | | hash: hash_2 | | hash: hash_3 |
+-----------------+ +-----------------+ +-----------------+
hash_N = SHA-256(prev_hash_bytes + JSON.stringify(record_data))
R2 is the immutable source of truth. D1 is a searchable index. If D1 is wiped, the chain in R2 remains intact and verifiable.
Setup
1. Install
npm install @stackbilt/audit-chain
2. Create the D1 table
npx wrangler d1 execute YOUR_DB --remote --file=node_modules/@stackbilt/audit-chain/schema.sql
Or copy schema.sql into your migrations directory.
3. Configure bindings
[[r2_buckets]]
binding = "AUDIT_BUCKET"
bucket_name = "your-audit-bucket"
[[d1_databases]]
binding = "AUDIT_DB"
database_name = "your-database"
database_id = "your-database-id"
Core API
writeRecord(bindings, opts)
Write a new audit record to R2 and index it in D1.
import { writeRecord, GENESIS_HASH } from '@stackbilt/audit-chain';
import type { AuditBindings } from '@stackbilt/audit-chain';
const bindings: AuditBindings = {
AUDIT_BUCKET: env.AUDIT_BUCKET,
AUDIT_DB: env.AUDIT_DB,
};
const { record, newChainHead } = await writeRecord(bindings, {
namespace: 'orders',
chainHead: currentHead, // start with GENESIS_HASH for a new chain
event_type: 'order.placed',
actor: 'user:alice',
payload: { order_id: 'ord_123', total: 99.99 },
metadata: { ip: '203.0.113.1' },
});
// Persist newChainHead for the next write
If the audit write fails, the audited action must not proceed.
| Parameter | Type | Description |
|---|---|---|
namespace |
string |
Chain namespace for isolation |
chainHead |
string |
Current chain head hash |
event_type |
string |
Application-defined event type |
actor |
string |
Who or what caused the event |
payload |
Record<string, unknown> |
Event data |
metadata |
Record<string, unknown> |
Optional metadata |
Returns { record: AuditRecord, newChainHead: string }.
verifyChain(bindings, namespace, opts?)
Verify hash chain integrity for an entire namespace. Recomputes every hash, confirms no branches, rejects missing records.
import { verifyChain } from '@stackbilt/audit-chain';
const result = await verifyChain(bindings, 'orders', {
expectedChainHead: currentHead, // detect tail truncation
expectedRecordCount: 42, // detect missing records
});
if (!result.valid) {
console.error(`Chain broken at ${result.broken_at}: ${result.error}`);
}
Returns VerificationResult: { valid, record_count, broken_at?, error? }.
queryIndex(db, opts)
Query the D1 index with filters.
import { queryIndex } from '@stackbilt/audit-chain';
const rows = await queryIndex(env.AUDIT_DB, {
namespace: 'orders',
event_type: 'order.placed',
actor: 'user:alice',
after: '2026-01-01T00:00:00Z',
limit: 50,
});
| Parameter | Type | Description |
|---|---|---|
namespace |
string |
Required |
event_type |
string |
Filter by event type |
actor |
string |
Filter by actor |
after / before |
string |
ISO 8601 time bounds |
limit |
number |
Max results (default 100, max 1000) |
offset |
number |
Pagination offset |
getRecord / getRecords
Retrieve records directly from R2 (authoritative):
const record = await getRecord(bindings, 'orders', recordId);
const all = await getRecords(bindings, 'orders'); // sorted ascending
Chain head management
The library does not manage the chain head — you must persist newChainHead and pass it back on each write. Appends for a namespace must be serialized; concurrent writers that share the same head will create a branch.
Recommended options:
| Storage | Notes |
|---|---|
| Durable Object | Single-writer guarantee — no race conditions. Recommended. |
| KV | Works if writes are serialized externally |
| D1 row | Fallback — query the latest record hash from the index |
If you lose the head, reconstruct it by reading the most recent R2 record and using its hash. Pass the persisted head to verifyChain() as expectedChainHead to detect missing tail records.
D1 schema
CREATE TABLE IF NOT EXISTS audit_index (
record_id TEXT PRIMARY KEY,
namespace TEXT NOT NULL,
event_type TEXT NOT NULL,
hash TEXT NOT NULL,
prev_hash TEXT NOT NULL,
actor TEXT NOT NULL,
timestamp TEXT NOT NULL,
payload_summary TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_namespace ON audit_index(namespace);
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_index(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_index(actor);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_index(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_ns_ts ON audit_index(namespace, timestamp);
Integration with evidence-core
@stackbilt/evidence-core produces content quality decisions; audit-chain makes them provable. The ./audit export in evidence-core shapes records for direct use with writeRecord().
import { validateEvidence } from '@stackbilt/evidence-core';
import { toAuditPayload } from '@stackbilt/evidence-core/audit';
import { writeRecord, getChainHead } from '@stackbilt/audit-chain';
const result = await validateEvidence(content, { policyVersion: 'google_march_2024_core' });
const auditRec = toAuditPayload(result, { contentId, namespace: `content:${contentId}` });
const chainHead = await getChainHead(bindings, auditRec.namespace);
await writeRecord(bindings, { ...auditRec, chainHead });
Canonical evidence event types
| Event type | When |
|---|---|
evidence.validation.completed |
After validateEvidence() runs |
evidence.assets.merged |
After mergeEvidence() |
evidence.gaps.detected |
Missing signals identified |
evidence.approval.granted |
Human or policy approves publish |
evidence.publish.allowed |
All gates clear |
evidence.publish.blocked |
One or more gates failed |
Design principles
- R2 is truth, D1 is convenience. If they diverge, R2 wins.
- Append-only. No update or delete operation.
- Fail loud. If the audit write fails, abort the audited operation.
- Namespace isolation. Multiple independent chains in the same R2 bucket and D1 table.
- Zero dependencies. Web Crypto API and Cloudflare bindings only.