Audit
Tamper-evident, append-only record of every privileged action — the forensic surface every compliance posture (HIPAA / PCI-DSS / SOC 2 / GDPR / SOX / DORA) bottoms out on. The audit_log table is baked into db.js's schema runner so apps cannot opt out; the chain is verified at boot and a break refuses-to-boot.
Hash chain: every row carries prevHash + rowHash computed over the SEALED form of the row plus a nonce. Verification recomputes directly from disk without unsealing — auditors can confirm integrity without holding the vault key. Periodic SLH-DSA-SHAKE-256f checkpoints (post-quantum signatures over the chain tip) anchor the chain to off-line evidence; tampering that recomputes hashes still fails checkpoint verification.
Namespaces: framework owns auth.* / system.* / audit.* / consent.* / subject.*; apps call registerNamespace("orders") at boot before emitting orders.created. Unregistered namespaces are rejected so typos don't become silent unobservable events.
Action shape — the 5W form: WHO (actor.userId / sessionId / ip / userAgent), WHAT (action = "namespace.verb[.qualifier]"), WHEN (recordedAt ms epoch + monotonic counter), WHERE (resource.kind / id), HOW (outcome ∈ {success, warning, failure, denied} + reason + metadata).
Two emit paths: - record(event) — async, throws on bad input, awaits the chain append. Use when the caller needs durability before continuing. - emit(event) / safeEmit(event) — synchronous fire-and-forget; events buffer in an AsyncHandler and drain serially through record(). safeEmit is drop-silent on malformed input by design: it runs in request hot paths where throwing would crash the request that triggered the audit attempt.
Reserved metadata keys: traceId (cross-request correlation, beginTrace() mints), parentEventId, before / after (state diff for change events), evidenceRef (pointer to signed PDF / ticket).
b.audit.registerNamespace(name) #
Register an action namespace at app bootstrap so record() / emit() accept events under it. Names must match [a-z][a-z0-9_]*. Calling twice is a no-op. Framework namespaces (auth / system / audit / consent / subject + every per-primitive namespace) are pre-registered.
b.audit.registerNamespace("orders");
b.audit.safeEmit({
action: "orders.shipped",
actor: { userId: "u-42" },
outcome: "success",
});
b.audit.refusedActions() #
Every action safeEmit declined to record, with how many times it was attempted. safeEmit absorbs a failing sink by design, and an action the validator refuses used to be absorbed the same way, so a primitive emitting a misspelled action reported success and wrote nothing. A non-empty list means rows are missing: register the namespace with b.audit.registerNamespace, or correct the action to namespace.verb[.qualifier] in lowercase.
The register holds 64 distinct actions and logs each one once. An application that derives an action from request data would otherwise let a client grow this map and the log without bound, one entry per distinct malformed value, on a path whose whole contract is to be drop-silent. Refusals past the 64th are counted under the action (overflow) and are neither stored individually nor logged.
b.audit.safeEmit({ action: "orders.Shipped", outcome: "success" });
b.audit.refusedActions();
// → [ { action: "orders.Shipped", attempts: 1 } ]
b.audit.record(event) #
{
actor: { userId, ip, userAgent, sessionId },
action: "namespace.verb[.qualifier]",
resource: { kind, id },
outcome: "success" | "warning" | "failure" | "denied", // warning = a partial result
reason: string,
metadata: object, // serialized to JSON
requestId: string,
}
Append one event to the audit chain and await durability. Throws on a bad action shape, an unregistered namespace, or an outcome outside {success, warning, failure, denied}. The chain-writer serializes the actual INSERT under a mutex so concurrent record() calls produce a strictly monotonic counter and a valid prevHash → rowHash chain.
Use record() when the caller must know the row landed before continuing (consent grants, break-glass unseals, change-control approvals). For request hot paths where best-effort is acceptable, prefer safeEmit().
One call appends exactly one row. The append's own storage work runs with audit's self-emission suppressed, as described on b.audit.safeEmit.
await b.audit.record({
actor: { userId: "u-42", ip: "10.0.0.1" },
action: "consent.granted",
resource: { kind: "purpose", id: "marketing" },
outcome: "success",
metadata: { traceId: b.audit.beginTrace() },
});
b.audit.useStore({ record }) #
{
record: async function (row), // operator's persistence callback
replaceChain: boolean, // default: false (shadow). true → redirect (skip the b.db chain)
}
Register an operator-supplied shadow store for every audit chain append. The framework's tamper-evident chain remains authoritative (HIPAA §164.312(b) / PCI-DSS Req 10 / SOX-404 / ISO 27001 A.12.4.1 posture preserved); the operator's record(row) async function is called AFTER each successful framework chain.append with the FULL appended row — { _id, recordedAt, monotonicCounter, prevHash, rowHash, action, outcome, actorUserId, ..., metadata } — so external consumers see identical hashes for cross-store reconciliation.
Typical use: replicate audit records to an immutable external destination (AWS QLDB / Azure Confidential Ledger / Google Cloud Audit Logs / an in-house WORM appliance / a SIEM forwarder). Operators in regulated industries often need their audit trail in a destination outside the application's own database for separation-of-duties (PCI-DSS Req 10.5.3) or independent retention (HIPAA §164.312(b) / SEC 17a-4 WORM).
Failure posture: if the operator's record throws / rejects / times out (30s hard cap — a stalled network call MUST NOT block the audit critical path), the shadow failure is surfaced via b.observability as either audit.shadow_failed (throw/reject) or audit.shadow_timeout (cap exceeded) with { action, monotonicCounter, error, timeoutMs } metadata, and the framework chain append still succeeds (the row is durable in the framework's own table; the shadow is a best-effort archival). Hot-path observability sinks emit drop-silent — an unreachable / hanging shadow MUST NOT crash or stall the request path that triggered the audit attempt.
Call this once at boot, BEFORE the first b.audit.record / b.audit.emit / b.audit.safeEmit. Switching stores on a running app strands every prior audit row in the previous shadow store — the framework chain has them, but the new shadow doesn't unless the operator backfills.
Pass null (or { record: null }) to unregister and revert to chain-only mode.
Audit's own reads and writes of its own tables do not emit, which is what keeps a chain write from auditing itself, and record is called inside that suppression. Your persistence needs to be: a store that writes through b.externalDb would otherwise emit system.externaldb.query on every write, have that handed straight back to it, and write again.
The same suppression is inherited by anything long-lived you build in there. A keepalive, a listener or a retry timer armed inside record is suppressed for the life of the process, and every audit event it emits afterwards is dropped with no error and no row. Arm it when the store is constructed, which is outside any chain write, or arm it inside b.audit.outsideSelfEmitSuppression.
Redirect mode — useStore({ record, replaceChain: true }): a consumer that owns its own database + audit layer and does NOT use b.db has no chain to shadow. In shadow mode every record() / emit() / safeEmit() still tries the b.db chain append first, which throws db/not-initialized on every emit (or silently drops from the emit handler). With replaceChain: true the shaped audit event is handed STRAIGHT to record(event) and the b.db chain append is skipped, so framework audit events (an SMTP insecure-TLS escape-hatch, mTLS negotiation at boot) land in the consumer's own tamper-evident log instead of erroring. In redirect mode the consumer store is authoritative: record()'s 30s timeout bounds a stalled callback, but a genuine store failure PROPAGATES to the caller (record() is the await-durability surface), while the emit() / safeEmit() handler-flush path drop-silent-catches it. The event passed to record is the shaped logical event ({ action, outcome, actorUserId, actorIp, resourceKind, resourceId, reason, metadata, requestId, ... }) — no framework _id / monotonicCounter / prevHash / rowHash, since there is no framework chain to hash against.
var b = require("@blamejs/core");
await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
await b.db.init({ dataDir: "/var/lib/blamejs" });
b.audit.useStore({
record: async function (row) {
// Replicate to AWS QLDB / Azure Confidential Ledger / etc.
await externalLedger.append({
id: row._id,
recordedAt: row.recordedAt,
monotonicCounter: row.monotonicCounter,
prevHash: row.prevHash,
rowHash: row.rowHash,
action: row.action,
outcome: row.outcome,
metadata: row.metadata,
});
},
});
// Every b.audit.* append now also lands in externalLedger.
b.audit.query(criteria) #
{
from: number | Date | string, // recordedAt >=
to: number | Date | string, // recordedAt <=
actorUserId: string,
resourceId: string,
action: string,
resourceKind: string,
outcome: "success" | "warning" | "failure" | "denied",
limit: number,
offset: number,
}
Read audit rows matching the criteria, returning unsealed rows for the auditor's view. Every call self-logs an audit.read event before returning (PCI DSS 10.2.3) so exfiltration attempts are forensically visible; the self-log is suppressed per-invocation only for a query whose own criteria targets action: "audit.read", so concurrent reads each record their own audit.read. Plain-field criteria translate into derived-hash equality where the column is sealed.
The audit.read row is the only row the read itself adds, since its own storage queries run with audit's self-emission suppressed, as described on b.audit.safeEmit. Unsealing the rows it returns is outside that suppression, so a sealed cell that will not open still records system.crypto.unseal_failed, and a tuple that trips the unseal rate cap still records the denied-outcome system.crypto.unseal_rate_exceeded. A read that cannot unseal what it returns is the event an auditor is looking for.
var rows = await b.audit.query({
action: "consent.granted",
from: Date.now() - 86400000,
limit: 100,
});
rows.length; // → 42
b.audit.beginTrace() #
Mint a fresh 32-hex-char trace id apps thread through linked events via metadata.traceId. Width matches the W3C traceparent trace-id format (16 random bytes hex-encoded), so the id is interoperable with OpenTelemetry / W3C Trace Context propagation.
var traceId = b.audit.beginTrace();
await b.audit.record({
action: "subject.export.requested",
outcome: "success",
metadata: { traceId: traceId },
});
await b.audit.record({
action: "subject.export.delivered",
outcome: "success",
metadata: { traceId: traceId, parentEventId: "..." },
});
b.audit.checkpoint(opts) #
{
skipIfUnchanged: boolean, // null-return when tip didn't move
}
Anchor the current chain tip with a fresh post-quantum signature (the configured b.auditSign algorithm — SLH-DSA-SHAKE-256f by default, ML-DSA-87 / ML-DSA-65 optional). Inserts a row into audit_checkpoints and updates the boot-time rollback-detection sidecar (single-node) or the cluster audit-tip row (cluster mode, fencing-token guarded). Cluster mode requires the caller hold leader status — cluster.requireLeader() throws otherwise.
Returns the inserted checkpoint row, or null when the chain is empty / skipIfUnchanged and the tip hasn't advanced.
The operation appends no audit row of its own, so the counter it reports anchoring is still the tip when it returns. Its reads and writes run with audit's self-emission suppressed, as described on b.audit.safeEmit.
var ckpt = await b.audit.checkpoint({ skipIfUnchanged: true });
if (ckpt) {
console.log("anchored at counter", ckpt.atMonotonicCounter);
}
b.audit.verifyCheckpoints() #
Walk every checkpoint and verify (a) the public-key fingerprint matches the current signing key, (b) the post-quantum signature over the payload still verifies, (c) the audit_log row at the anchored counter still has the recorded rowHash. Catches tampering that recomputed chain hashes after holding the vault key, because the off-chain signature anchor is unforgeable without the signing key.
Returns { ok: true, checkpointsVerified } on success, or { ok: false, checkpointsVerified, breakAt, checkpointId, reason } at the first break.
var result = await b.audit.verifyCheckpoints();
if (!result.ok) {
throw new Error("audit checkpoint break at " + result.breakAt +
": " + result.reason);
}
result.checkpointsVerified; // → 17
b.audit.verify(opts) #
{
from: number, // start counter (incremental verify after a known-good checkpoint)
to: number, // end counter
}
Walk every audit_log row in monotonic order and recompute each rowHash against the canonicalized columns + nonce, confirming each row's prevHash matches the previous row's rowHash. Catches any insert / delete / mutation between checkpoints. Runs at boot in db.init(); operators also call it from a periodic job.
Returns { ok: true, rowsVerified } on a clean chain, or { ok: false, rowsVerified, breakAt, reason } at the first break.
The walk appends no audit row of its own, so rowsVerified describes the chain as it stood when the walk began and repeating the call over an idle chain gives the same answer.
var result = await b.audit.verify();
if (!result.ok) {
console.error("audit chain break at row", result.breakAt);
process.exit(1);
}
b.audit.emit(event) #
Synchronous fire-and-forget emit — events buffer in an AsyncHandler and drain serially through record(). Returns immediately; never returns a Promise. Unlike safeEmit(), emit() does NOT normalize outcome / action and does NOT redact metadata — callers pass already- shaped events. Most call sites should prefer safeEmit instead; emit is the lower-level surface the framework's own bound-actor wrapper uses.
An event emitted while audit is reading or writing its own tables is dropped and counted, as described on b.audit.safeEmit.
b.audit.emit({
actor: { userId: "u-42" },
action: "system.config.reloaded",
outcome: "success",
metadata: { source: "SIGHUP" },
});
b.audit.outsideSelfEmitSuppression(fn) #
Run fn with audit's self-emit suppression lifted, and return whatever it returns. Use it to build something long-lived from inside a record on a store registered with b.audit.useStore.
While audit writes its own chain it suppresses emissions, so a chain write does not audit itself into a loop. That suppression is inherited by whatever runs inside it, including a resource that outlives the call: a keepalive, a listener or a retry timer a store arms inside its own record is suppressed for the life of the process, and every audit event it emits afterwards is dropped with no error and no row. Arm such a resource when the store is constructed, which is outside any chain write, or arm it inside this wrapper.
Wrap only the arming. A store's own persistence belongs inside the suppression: if its writes go through b.externalDb, each one emits system.externaldb.query, which is handed back to the store, which writes again.
An fn that is not a function raises a TypeError naming this primitive.
var heartbeat = null;
b.audit.useStore({
replaceChain: true,
record: async function (row) {
if (heartbeat === null) {
heartbeat = b.audit.outsideSelfEmitSuppression(function () {
return setInterval(function () { ledger.ping(); }, 30000);
});
}
await ledger.append(row);
},
});
b.audit.safeEmit(event) #
{
actor: { userId, ip, userAgent, sessionId },
action: "namespace.verb[.qualifier]",
resource: { kind, id },
outcome: string, // normalized
reason: string, // redacted
metadata: object, // redacted
requestId: string,
}
Hot-path-safe fire-and-forget audit emit. Drop-silent on malformed input by design — safeEmit runs from request middleware, log-stream hooks, and finalizers where throwing on a missing action would crash the request that triggered the audit attempt. Operators who need durability guarantees call record() and await it.
Built-in normalization: action segments with hyphens become underscores ("biometric-id" → "biometric_id"); outcome aliases collapse to {success, failure, denied} ("ok" → "success", "error" → "failure", "refused" → "denied"). Actor / reason / metadata pass through b.redact.redact() so connection strings, JWTs, PEM blocks, AWS keys, and SSNs are scrubbed before they reach the chain.
An emission made while audit is reading or writing its own tables is dropped, and counted as the observability event audit.self_emit_suppressed with the action that was dropped. In cluster mode the chain write runs through b.externalDb, which audits every query it issues, so without this an append would emit an event that becomes another append. The suppression covers that storage call and nothing else: it is scoped to the call tree of audit's own reads and writes, so an emission from a request running at the same time is unaffected, and so is one from work an audit operation performs around its storage, such as unsealing the rows a read returns or signing a checkpoint.
b.audit.safeEmit({
actor: { userId: req.user && req.user.id },
action: "auth.login",
outcome: "success",
metadata: { traceId: req.traceId, ua: req.headers["user-agent"] },
});
b.audit.namespaced(prefix, opts?) #
{
audit: boolean, // false disables the emitter (default on); passing a bare boolean === { audit }
sink: object, // alternate audit target with a .safeEmit(event) (defaults to b.audit)
}
Build a drop-silent emitter bound to one action namespace — the shape every framework primitive hand-rolled as a private _emitAudit(action, outcome, metadata) closure (or inline) (if (!on) return; try { safeEmit({ action: "ns." + action, outcome, metadata }); } catch {}). The returned function prefixes action with prefix + ".", fills metadata with {} when omitted, and routes through safeEmit (so the same redaction + outcome normalization applies).
Every caller drives the SAME 4-argument emitter (action, outcome, metadata, extra?): extra is an object whose fields are merged onto the event, which carries the only per-emit variations seen across the framework — actor (constant { type: "system" } for an unattended worker, or a per-request ctx.actor) and resource. So a hand-rolled emitter with extra event fields is never an exception — pass them through extra. opts is the gate flag for the common case OR { audit, sink }, where sink emits to an operator-supplied audit object instead of the framework chain (the emitter is a no-op if that sink has no safeEmit, matching the hand-rolled sink guard).
A falsy prefix (null / "") builds the no-namespace variant: action passes through verbatim (no prefix + "."). This serves the primitives whose audit actions are already fully-qualified at the call site (emitAudit( "system.outbox.started", …)) — the same gated drop-silent passthrough, without re-homing the qualifier.
var emitAudit = b.audit.namespaced("gdpr.ropa", opts.audit);
emitAudit("activity_added", "success", { activityId: id });
// → safeEmit({ action: "gdpr.ropa.activity_added", outcome: "success",
// metadata: { activityId: id } })
var emitGate = b.audit.namespaced("guardsql.gate");
emitGate("refused", "denied", { route: r }, { actor: ctx.actor }); // per-call actor
b.audit.flush() #
Drain the AsyncHandler buffer — every queued emit() / safeEmit() lands in the audit chain before the returned Promise resolves. Tests, graceful shutdown, and any code that needs to read audit_log immediately after emitting awaits flush().
b.audit.safeEmit({ action: "system.shutdown.requested", outcome: "success" });
await b.audit.flush();
var rows = await b.audit.query({ action: "system.shutdown.requested" });
rows.length; // → 1
b.audit.bindActor(actorId, opts) #
{
roleEquivalent: function (actorId, sqlRole) -> boolean,
}
Wrap safeEmit / record so any event whose actor.userId doesn't match the bound id is refused (and an audit.actor_binding.violation event is recorded under the bound actor). When opts.roleEquivalent is provided and the caller is inside a db-role-context.runWithRole scope, the SQL-bound role and bound actor must agree per the operator-supplied mapping.
Pair with generateActorBindingTriggerSql() for SQL-side enforcement — application-layer binding catches typos; the trigger catches privileged callers bypassing the framework.
var bound = b.audit.bindActor("u-42");
bound.safeEmit({
actor: { userId: "u-42" },
action: "orders.shipped",
outcome: "success",
});
bound.safeEmit({
actor: { userId: "u-other" },
action: "orders.shipped",
outcome: "success",
});
// → drops + records "audit.actor_binding.violation" under u-42
b.audit.generateActorBindingTriggerSql(opts) #
{
column: string, // default "actorUserId"
tableName: string, // default "_blamejs_audit_log"
roleMappingFn: string, // SQL fn name mapping actor → role
allowRoles: string[], // roles that bypass the check
}
Emit Postgres trigger DDL that refuses INSERTs into the audit_log table whose stored actorUserId column doesn't match the SQL session's current_user. Operators apply the returned up script via b.externalDb.migrate under sox-404 / soc2 posture so a privileged caller (operator script, migration runner) can't write audit rows under a different actor identity.
Returns { up, down, functionName, triggerName } for migration runner symmetry.
var ddl = b.audit.generateActorBindingTriggerSql({
allowRoles: ["blamejs_service"],
});
await db.query(ddl.up);
b.audit.assertSegregation(opts) #
{
db: { query(sql, params) -> { rows } }, // required
functionName: string,
triggerName: string,
}
Boot-time check that confirms the actor-binding trigger function and trigger row exist in the externalDb's pg_proc / pg_trigger catalogs. Throws AuditSegregationError with the missing artifacts named when either is absent — operators wire this into the sox-404 / soc2 boot sequence so a forgotten migration refuses-to-boot instead of silently shipping without enforcement.
await b.audit.assertSegregation({ db: externalDb });
// throws if the trigger DDL hasn't been applied
b.audit.applyPosture(posture) #
Cascade hook called by b.compliance.set(posture) to record the active regulatory regime. The chain itself is posture-agnostic — every posture audits with the same SLH-DSA-SHAKE-256f signing key — but downstream tooling (forensic export, SIEM correlation) reads the stored posture to filter / route. Returns { posture } on accept, null on a non-string / empty argument.
b.audit.applyPosture("hipaa");
b.audit.activePosture(); // → "hipaa"
b.audit.activePosture() #
Return the posture string most recently passed to applyPosture(), or null if none has been set. Read-only accessor for downstream tooling that tags audit-derived artifacts with the regime.
b.audit.applyPosture("pci-dss");
b.audit.activePosture(); // → "pci-dss"