@gomagentic/verdict-audit
Async audit pipeline: batched, bounded, retrying. Never blocks a decision.
Part of Verdict — a serverless-first authorization engine. Policies (RBAC / ABAC / ReBAC) compile once and decide in microseconds, embedded in your app, behind a central PDP, or synced to the edge.
Authorization decisions are the hot path; writing the audit trail is not. This sink buffers decision records in memory and flushes them in batches to your store or queue. The buffer is bounded: past its limit it drops the oldest records — and counts the drops — rather than growing without limit or making a request wait on audit I/O. An audit outage degrades to dropped records, never to failed or slowed decisions.
Install
npm install @gomagentic/verdict-audit
Usage
Wrap any downstream sink — anything with writeAudit(records), such as a @gomagentic/verdict-store audit sink — in a BufferedAuditSink. Calls to writeAudit append to the buffer and return immediately; flushes happen on batch size, on interval, or explicitly.
import { BufferedAuditSink } from "@gomagentic/verdict-audit";
import type { AuditRecord } from "@gomagentic/verdict-core";
// `store` is any AuditSinkLike: async writeAudit(records: readonly AuditRecord[])
const sink = new BufferedAuditSink(store, {
maxBatch: 200, // records per downstream write (default 200)
flushIntervalMs: 2_000, // time-based flush (default 2000 ms)
maxBuffered: 10_000, // bounded buffer; oldest dropped beyond this (default 10 000)
onError: (err) => console.error("audit write failed", err),
onDrop: (count) => console.warn(`audit dropped ${count} records`),
});
// On the request path: fire-and-forget, never awaited on the hot path.
await sink.writeAudit(records); // returns as soon as the records are buffered
// On Workers, drain in the background instead of blocking the response:
ctx.waitUntil(sink.flush());
// On shutdown (or between requests elsewhere): final drain.
await sink.stop(); // flushes, then rejects further records
flush() drains the buffer in maxBatch chunks and coalesces concurrent callers; stop() performs a final flush and stops accepting records.
Backpressure & drops
The buffer is capped at maxBuffered. When it would overflow, the oldest records are evicted (recent history wins) and the drop count is incremented — onDrop fires with the number evicted. A downstream write failure increments failures, invokes onError, and re-queues the batch at the front (subject to the same bound) to retry on the next write, interval, or explicit flush().
The stats getter exposes live counters:
const { buffered, written, dropped, failures } = sink.stats;
// buffered — records currently in memory awaiting flush
// written — records successfully handed to the downstream sink
// dropped — records evicted because the buffer was full
// failures — downstream write failures (each re-queues its batch)
Queue adapter
QueueAuditSink adapts a batch queue — anything exposing sendBatch(messages: { body: AuditRecord }[]), such as Cloudflare Queues, SQS, or a Kafka producer — into an AuditSinkLike. Each record becomes one queue message for durable, out-of-band delivery. Compose it under a BufferedAuditSink to get batching and bounded buffering in front of the queue:
import { BufferedAuditSink, QueueAuditSink } from "@gomagentic/verdict-audit";
// `env.AUDIT_QUEUE` is a Cloudflare Queue binding (has sendBatch)
const sink = new BufferedAuditSink(new QueueAuditSink(env.AUDIT_QUEUE));
Documentation
License
Apache-2.0