mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to lib/audit.mjs
per ADR 0008 § 5 + ships an external cron tool. Rotation is
SYNCHRONOUS at v0.3.0 — synchronous design eliminates the race that
an async wrapper would create between date-change-detection and the
append.
lib/audit.mjs EXTENSIONS:
- New _maybeRotateAudit({ olpHome, logEvent }) (sync): probes live
audit.ndjson; if it holds events from past UTC date, renames it
to audit-YYYY-MM-DD.ndjson. Idempotent. If target file exists
(cron beat in-server check), logs warn + skips per § 5.3.
- appendAuditEvent extended: cheap fast-path date check via module-
cached _lastSeenUtcDate. On date change, calls _maybeRotateAudit
synchronously BEFORE appendFileSync — so old-date events land in
the rotated file and new-date events land in the fresh live file.
No event straddles the boundary.
- Why SYNCHRONOUS: an async wrapper would let the sync
appendFileSync race the not-yet-completed renameSync, landing
today's event in the about-to-be-renamed file. Sync rotation is
the only correct ordering at the append-fired-from-many-routes
scale OLP runs. (Test 26b-1 caught this during local run; the
initial async-wrapper implementation failed because the live
file at assertion time didn't exist.)
- New exports: _maybeRotateAudit (sync), getAuditRotateCount,
getAuditRotateFailCount, __resetAuditRotateState,
__setLastSeenUtcDateForTesting.
- First-event-date discovery: when probing the live file's date,
reads only the first ndjson line + parses its ts. Falls back to
file mtime if events absent (corrupt/empty edge).
bin/olp-audit-rotate.mjs (~95 lines): external cron tool per § 5.2.
Calls _maybeRotateAudit once + reports outcome. Exit codes 0
(success or no-op), 1 (bad usage), 2 (rotation failed). Installed
via package.json bin so `npx olp-audit-rotate [--olp-home=<path>]`
works. Example cron line in file header.
CONCURRENT-SAFETY (§ 5.3):
In-process sequential appends after the first date-change detection
short-circuit via the updated _lastSeenUtcDate cache → exactly 1
rename even under N sequential appends. Cross-process (cron + server)
coexistence handled by the "target already exists → skip + warn"
branch.
TESTS — Suite 26, +12 (588 → 600):
26a-1..5: _maybeRotateAudit (no live file / today already /
yesterday→rotate / idempotent re-call / cron-race target-exists
warn)
26b-1: appendAuditEvent past UTC date change triggers sync rotation
+ append lands in fresh live file
26c-1: 10 sequential appendAuditEvent across date change → exactly
1 rotation + all 10 events in new live file
26d-1..4: bin/olp-audit-rotate.mjs CLI (--help / no-live-file /
yesterday-file-rotates / unknown-flag exit 1)
26e-1: rotated files queryable via lib/audit-query.mjs
discoverAuditFiles + readAuditWindow cross-file read
package.json: bin.olp-audit-rotate + scripts.olp-audit-rotate entries
added.
DOCUMENTATION:
- AGENTS.md: lib/audit.mjs marker promoted ✅ (D45 append + D52
rotation both shipped); new bin/olp-audit-rotate.mjs entry.
- CHANGELOG.md: D52 entry under Unreleased per release_kit overlay.
NOT IN D52:
- tried_providers schema fix (D53; D45 P2 deferral)
- E2E + docs polish (D54)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 588 → 600 (+12). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger).
- ADR 0008 § 5.2 (external cron alternative).
- ADR 0008 § 5.3 (concurrent-rotation safety + cron-coexistence).
- ADR 0008 § 5.4 (renamed-file query path consumed by D49 lib/
audit-query.mjs).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: extends lib/audit.mjs (a Phase 2 internal
module) + adds new bin/ CLI + small package.json bin/scripts entries.
No provider plugin / entry surface / IR change.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
/**
|
|
* lib/audit.mjs — OLP audit ndjson append (Phase 2 / D45)
|
|
*
|
|
* Authority: ADR 0007 § 6.2 (audit append semantics) + § 8 (event schema).
|
|
*
|
|
* Behaviour:
|
|
* - One JSON event per line; newline-terminated; UTF-8.
|
|
* - Append to ~/.olp/logs/audit.ndjson (chmod 0600 file, 0700 dir).
|
|
* - On append failure: log a warn ('audit_append_failed_once') + retry
|
|
* once synchronously.
|
|
* - On second-failure: increment per-process drop counter + log warn
|
|
* ('audit_append_dropped'); NEVER throw to the caller (audit is
|
|
* observability, not authorization). Per § 6.2.
|
|
* - No memory buffer at Phase 2 (forward-path note in ADR § 13).
|
|
*
|
|
* Atomicity note: Node's `fs.appendFileSync` opens with O_APPEND which is
|
|
* POSIX-atomic for writes <= PIPE_BUF (typically 4096 bytes). Our event
|
|
* payloads (§ 8 schema with hash + shape fields, no PII / no message
|
|
* content) are well under that limit, so concurrent in-process appends
|
|
* are line-atomic without explicit locking.
|
|
*
|
|
* No PII: § 8 explicitly excludes request body, response body, and IR
|
|
* message content. Hash + shape only.
|
|
*/
|
|
|
|
import { appendFileSync, mkdirSync, chmodSync, renameSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
|
const OLP_HOME_ENV = 'OLP_HOME';
|
|
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
|
|
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
|
const ROTATED_FILE_PREFIX = 'audit-';
|
|
const ROTATED_FILE_SUFFIX = '.ndjson';
|
|
|
|
let _dropCounter = 0;
|
|
let _rotateCounter = 0; // observability + test assertion target
|
|
let _rotateFailCounter = 0;
|
|
// Module-cached "last UTC date we saw at append time" so we don't read
|
|
// disk metadata on every append just to check for rotation.
|
|
let _lastSeenUtcDate = _utcDateNow();
|
|
|
|
/**
|
|
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
|
|
* process.env.OLP_HOME → ~/.olp. Resolved per call so tests setting the
|
|
* env mid-run take effect.
|
|
*/
|
|
function _resolveOlpHome(opts) {
|
|
if (opts?.olpHome) return opts.olpHome;
|
|
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
|
return DEFAULT_OLP_HOME;
|
|
}
|
|
|
|
/**
|
|
* Current UTC date as YYYY-MM-DD. Module-private; reused by rotation logic.
|
|
*/
|
|
function _utcDateNow() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Read the first event's `ts` from the live audit file to discover the
|
|
* date it was opened on (for stale-cache recovery when the process is
|
|
* restarted and the in-memory `_lastSeenUtcDate` doesn't match). Returns
|
|
* the YYYY-MM-DD string, or null if the file is missing / empty /
|
|
* malformed.
|
|
*/
|
|
function _firstEventDateInLiveFile(livePath) {
|
|
try {
|
|
if (!existsSync(livePath)) return null;
|
|
// Read the file + slice off the first ndjson line. For audit ndjson the
|
|
// first line is at most a few hundred bytes; this is fine for the rare
|
|
// "process restart with a stale live file" recovery path. (Family-scale
|
|
// single-file audit is bounded; multi-MB scans are not a real concern
|
|
// until Phase 4+ when Option 3 SQLite migration would kick in anyway.)
|
|
const raw = readFileSync(livePath, 'utf-8');
|
|
const nl = raw.indexOf('\n');
|
|
const firstLine = nl === -1 ? raw : raw.slice(0, nl);
|
|
if (!firstLine) return null;
|
|
const obj = JSON.parse(firstLine);
|
|
if (typeof obj?.ts !== 'string') return null;
|
|
return obj.ts.slice(0, 10);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotate the live audit file (if any) to its UTC-date suffix. Idempotent:
|
|
* if a rotated file with that name already exists (e.g., cron beat us to
|
|
* it), this skips the rename.
|
|
*
|
|
* Per ADR 0008 § 5.1 + § 5.3. SYNCHRONOUS so callers (appendAuditEvent +
|
|
* external cron) can rely on it completing before the next IO operation.
|
|
* Sync rotation eliminates the race that an async wrapper would create
|
|
* between the date-change-detection and the append (where the append could
|
|
* land in the old un-rotated file).
|
|
*
|
|
* Concurrent in-process invocations: Node's single-threaded event loop
|
|
* serializes synchronous calls within a tick. Cross-tick concurrency
|
|
* uses the `_lastSeenUtcDate` cache as the gate — once set, subsequent
|
|
* appends short-circuit the rotation check.
|
|
*
|
|
* @param {object} args - { olpHome, logEvent, _nowFn (test injection) }
|
|
* @returns {{ rotated: boolean, fromPath?: string, toPath?: string, dateUsed?: string }}
|
|
*/
|
|
export function _maybeRotateAudit(args = {}) {
|
|
const olpHome = _resolveOlpHome(args);
|
|
const logEvent = args.logEvent ?? ((level, ev, data) => {
|
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
});
|
|
const nowFn = args._nowFn ?? (() => new Date());
|
|
|
|
const logsDir = join(olpHome, 'logs');
|
|
const livePath = join(logsDir, LIVE_AUDIT_FILE);
|
|
if (!existsSync(livePath)) {
|
|
// No live file yet (first append ever); no rotation needed.
|
|
_lastSeenUtcDate = nowFn().toISOString().slice(0, 10);
|
|
return { rotated: false };
|
|
}
|
|
// Determine the "date the live file holds" — use the first event's ts.
|
|
// Fall back to file mtime if events absent (corrupt / empty file edge).
|
|
let fileDate = _firstEventDateInLiveFile(livePath);
|
|
if (fileDate === null) {
|
|
try {
|
|
fileDate = statSync(livePath).mtime.toISOString().slice(0, 10);
|
|
} catch {
|
|
return { rotated: false };
|
|
}
|
|
}
|
|
const today = nowFn().toISOString().slice(0, 10);
|
|
if (fileDate === today) {
|
|
_lastSeenUtcDate = today;
|
|
return { rotated: false };
|
|
}
|
|
|
|
// Rotate: rename live → audit-<fileDate>.ndjson.
|
|
const rotatedName = `${ROTATED_FILE_PREFIX}${fileDate}${ROTATED_FILE_SUFFIX}`;
|
|
const rotatedPath = join(logsDir, rotatedName);
|
|
if (existsSync(rotatedPath)) {
|
|
// Cron or another writer beat us; the live file holding fileDate's
|
|
// events must be merged manually. Per ADR 0008 § 5.3 we log + skip.
|
|
logEvent('warn', 'audit_rotate_target_exists', {
|
|
livePath, rotatedPath,
|
|
message: 'rotation target exists — concurrent rotator beat in-process check; manual merge required if events overlap',
|
|
});
|
|
_lastSeenUtcDate = today;
|
|
return { rotated: false };
|
|
}
|
|
try {
|
|
renameSync(livePath, rotatedPath);
|
|
_rotateCounter++;
|
|
_lastSeenUtcDate = today;
|
|
logEvent('info', 'audit_rotated', { fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate });
|
|
return { rotated: true, fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate };
|
|
} catch (err) {
|
|
_rotateFailCounter++;
|
|
logEvent('warn', 'audit_rotate_failed', {
|
|
livePath, rotatedPath,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
// Don't update _lastSeenUtcDate so the next append retries.
|
|
return { rotated: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a single audit event to ~/.olp/logs/audit.ndjson.
|
|
*
|
|
* @param {object} event - § 8 schema fields (ts, key_id, owner_tier,
|
|
* method, path, provider, model, status_code, latency_ms, cache_status,
|
|
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
|
|
* Caller is responsible for populating fields; missing fields are
|
|
* serialized as undefined → omitted by JSON.stringify.
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
|
|
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
|
|
* - injectable structured logger; defaults to console.warn with JSON line
|
|
*/
|
|
export function appendAuditEvent(event, opts = {}) {
|
|
const olpHome = _resolveOlpHome(opts);
|
|
const logsDir = join(olpHome, 'logs');
|
|
const path = join(logsDir, LIVE_AUDIT_FILE);
|
|
const line = JSON.stringify(event) + '\n';
|
|
const logEvent = opts.logEvent ?? ((level, ev, data) => {
|
|
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
|
|
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
});
|
|
|
|
// D52: cheap fast-path date check. If the module-cached date matches the
|
|
// current UTC date, skip the rotation probe entirely (no disk I/O). If
|
|
// the date has changed, synchronously rotate BEFORE the append so the
|
|
// append lands in the (post-rotation) new live file rather than the
|
|
// about-to-rotate-away old one.
|
|
// Per ADR 0008 § 5.1: rotation fires "on the first append after a UTC
|
|
// date change." Synchronous rotation ensures no append straddles the
|
|
// boundary — old-date events land in the rotated file; new-date events
|
|
// land in the fresh live file. The cache flip happens INSIDE
|
|
// _maybeRotateAudit so concurrent in-process re-triggers short-circuit.
|
|
const today = _utcDateNow();
|
|
if (today !== _lastSeenUtcDate) {
|
|
_maybeRotateAudit({ olpHome, logEvent });
|
|
}
|
|
|
|
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
|
try {
|
|
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
// Tighten dir mode in case it already existed with broader permissions.
|
|
try { chmodSync(logsDir, 0o700); } catch { /* tolerate EPERM */ }
|
|
appendFileSync(path, line, { mode: 0o600 });
|
|
return;
|
|
} catch (err) {
|
|
if (attempt < RETRY_COUNT) {
|
|
logEvent('warn', 'audit_append_failed_once', {
|
|
path,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
continue;
|
|
}
|
|
_dropCounter++;
|
|
logEvent('warn', 'audit_append_dropped', {
|
|
path,
|
|
error: err?.message ?? String(err),
|
|
drop_count: _dropCounter,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-process count of audit events dropped due to repeated append failure.
|
|
* Useful for /health observability surface (D46).
|
|
*/
|
|
export function getAuditDropCount() {
|
|
return _dropCounter;
|
|
}
|
|
|
|
/**
|
|
* Test-only: reset the drop counter to zero. Suite 20 uses this between
|
|
* cases to assert independent failure-handling counts.
|
|
*/
|
|
export function __resetAuditDropCount() {
|
|
_dropCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* Per-process count of successful rotations performed. Test + future
|
|
* observability surface.
|
|
*/
|
|
export function getAuditRotateCount() {
|
|
return _rotateCounter;
|
|
}
|
|
|
|
/**
|
|
* Per-process count of failed rotation attempts (rename threw, target
|
|
* existed, etc.). Test + future observability surface.
|
|
*/
|
|
export function getAuditRotateFailCount() {
|
|
return _rotateFailCounter;
|
|
}
|
|
|
|
/**
|
|
* Test-only: reset the rotation counters + the in-process "last seen UTC
|
|
* date" cache so each test exercises a fresh code path.
|
|
*/
|
|
export function __resetAuditRotateState() {
|
|
_rotateCounter = 0;
|
|
_rotateFailCounter = 0;
|
|
_lastSeenUtcDate = _utcDateNow();
|
|
}
|
|
|
|
/**
|
|
* Test-only: force the cached "last seen UTC date" to a specific value
|
|
* so the next appendAuditEvent observes a date change and triggers
|
|
* rotation deterministically.
|
|
*/
|
|
export function __setLastSeenUtcDateForTesting(dateStr) {
|
|
_lastSeenUtcDate = dateStr;
|
|
}
|