/** * `pruneAudit(...)` - drop a contiguous prefix of the audit chain * while preserving the integrity of the surviving suffix. * * The retention policy is a deliberate trade-off: the framework keeps * audit entries forever by default, but operators can delete * everything older than `before` provided that the surviving prefix * keeps at least `retain` entries. * * Only the longest *contiguous* run of fully-expired entries at the * front of the chain is removed. Because the chain is a hash chain it * can only be trimmed at the front, so an expired entry that sits (by * `seq`) behind a not-yet-expired one - e.g. when timestamps are not * monotonic in `seq` order - is retained rather than punching a hole in * the chain. Deleting fewer entries than the wall-clock cutoff implies * is the safe behaviour here, never a silent gap. * * After deletion the first surviving entry's `prevHash` is rewritten * to the genesis value so `verifyAuditChain(...)` keeps returning * `{ ok: true }` on the surviving suffix. This recomputes every * surviving entry's `hash`, so any entry hashes previously archived via * `exportAudit(...)` will no longer match the post-prune chain - treat a * prune as invalidating the hashes of earlier exports. * * The same applies to the D4 Merkle layer: the RFC-6962 leaves * hash each entry's canonical JSON INCLUDING `prevHash`/`hash`, so * `verifyAuditAgainstCheckpoint(...)` against ANY checkpoint signed * before the prune fails afterwards - BY DESIGN, and indistinguishably * from a truncate-and-re-root attack. Every retention prune must * therefore end with signing + distributing a fresh checkpoint and * marking the old anchors superseded; see the "Retention and * anchoring" runbook in the security guide. * * @packageDocumentation */ import { computeAuditHash, GENESIS_PREV_HASH } from './append.js'; import type { AuditDb } from './audit-db.js'; import type { StoredAuditEntry } from './types.js'; /** Survivors re-hashed per closed-iterator batch (see pruneAudit body). */ const REWRITE_BATCH_SIZE = 500; /** * Options for `pruneAudit(...)`. * * @stable */ export interface PruneAuditOptions { /** * Drop entries older than this Date / epoch ms. Required so the * helper never silently truncates the audit chain. */ readonly before: Date | number; /** * Minimum number of entries that must survive. The helper refuses * to leave the chain emptier than this. Defaults to 1. */ readonly retain?: number; /** * Optional structured log sink invoked exactly once per prune run. * The framework logger ships in a follow-on phase; until then, * consumers can wire `console.info` (or a custom logger) here so * the operational signal is not lost. */ readonly logger?: (event: PruneAuditLogEvent) => void; } /** * Structured shape of the single log event emitted per prune run. * * @stable */ export interface PruneAuditLogEvent { readonly level: 'info'; readonly message: string; readonly deleted: number; readonly firstSurvivingSeq?: number; readonly retain: number; readonly before: number; } /** * Result of `pruneAudit(...)`. * * @stable */ export interface PruneAuditResult { readonly deleted: number; /** Sequence number of the first surviving entry, or `undefined` if empty. */ readonly firstSurvivingSeq?: number; } /** * Drop entries older than `before`, leaving at least `retain` * entries. Maintains the chain integrity by rewriting the first * surviving entry's `prevHash` to the genesis value. * * @stable */ export async function pruneAudit( db: AuditDb, options: PruneAuditOptions, ): Promise { const beforeMs = options.before instanceof Date ? options.before.getTime() : options.before; if (!Number.isFinite(beforeMs)) { throw new RangeError(`pruneAudit: 'before' must be finite; got ${String(options.before)}.`); } // W-011: the delete+rewrite MUST be one write transaction. Without // the fence, an append racing the rewrite loop hashes against a // pre-prune tip and the chain verifies broken FOREVER after. A prune // that cannot be made atomic is worse than no prune - fail closed. if (db.transact === undefined) { throw new Error( `pruneAudit: the '${db.binding}' audit-db binding does not implement transact(), ` + 'so the prune cannot run atomically against live appends. Upgrade the binding ' + '(the default @graphorin/store-sqlite binding implements it) or stop all writers first.', ); } const retain = Math.max(1, options.retain ?? 1); const { deleted, firstSurviving } = await db.transact(async () => { const total = await db.count(); if (total === 0) { return { deleted: 0, firstSurviving: undefined as StoredAuditEntry | undefined }; } // Collect candidate seqs while respecting the retain floor. We // walk the iterator twice - once to identify the threshold and // once to find the first surviving entry - so we never have to // load the entire chain into memory. let lastQualifyingSeq: number | undefined; let walked = 0; for await (const entry of db.iterate()) { walked += 1; if (entry.ts >= beforeMs) break; if (total - walked < retain) break; lastQualifyingSeq = entry.seq; } if (lastQualifyingSeq === undefined) { return { deleted: 0, firstSurviving: undefined as StoredAuditEntry | undefined }; } const removed = await db.deleteUpTo(lastQualifyingSeq); // Reroot the surviving chain at the genesis prev-hash. Every // surviving entry's `prevHash` (and consequently its `hash`) is // recomputed so `verifyAuditChain` keeps reporting a clean chain // on the trimmed log. The cryptographic link to the deleted prefix // is severed by design - that is the documented retention contract. // // The rewrite is BATCHED (W-062): `iterate()` may hold a live // statement on the underlying connection (the shipped // better-sqlite3 binding does), and writing through the same // connection while an iterator is open throws // "This database connection is busy executing a query". Collect a // bounded batch, let the iterator CLOSE (the early `break` // propagates return() through the generator), then rewrite; memory // stays bounded by the batch size instead of the chain length. let prevHash = GENESIS_PREV_HASH; let first: StoredAuditEntry | undefined; let fromSeq = 0; for (;;) { const batch: StoredAuditEntry[] = []; for await (const entry of db.iterate({ fromSeq })) { batch.push(entry); if (batch.length >= REWRITE_BATCH_SIZE) break; } if (batch.length === 0) break; for (const entry of batch) { const rewritten = withRehashedChain(entry, prevHash); await db.replaceEntry(rewritten); if (first === undefined) first = rewritten; prevHash = rewritten.hash; } const lastInBatch = batch[batch.length - 1]; if (lastInBatch === undefined) break; fromSeq = lastInBatch.seq + 1; } return { deleted: removed, firstSurviving: first }; }); const result: PruneAuditResult = Object.freeze({ deleted, ...(firstSurviving === undefined ? {} : { firstSurvivingSeq: firstSurviving.seq }), }); options.logger?.( Object.freeze({ level: 'info', message: firstSurviving === undefined ? `pruneAudit: deleted ${deleted} entries (chain is now empty)` : `pruneAudit: deleted ${deleted} entries; surviving chain rerooted at seq ${firstSurviving.seq}`, deleted, ...(firstSurviving === undefined ? {} : { firstSurvivingSeq: firstSurviving.seq }), retain, before: beforeMs, }), ); return result; } function withRehashedChain(entry: StoredAuditEntry, prevHash: string): StoredAuditEntry { const { hash: _ignored, ...withoutHash } = entry; void _ignored; const baseEntry: Omit = Object.freeze({ ...withoutHash, prevHash, }); const hash = computeAuditHash(baseEntry); return Object.freeze({ ...baseEntry, hash }); }