feat+test+docs: D52 — daily audit rotation (lib/audit.mjs + bin/olp-audit-rotate.mjs) (#29)

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>
This commit is contained in:
dtzp555-max
2026-05-25 16:27:26 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 251b578114
commit 408d5a839a
6 changed files with 530 additions and 5 deletions
+233
View File
@@ -11726,3 +11726,236 @@ describe('Suite 25 — D51 dashboard.html UI smoke (Phase 3, ADR 0008 § 6)', ()
assert.match(r.body, /401.*owner-tier/i, 'dashboard error banner must mention owner-tier in 401 case');
});
});
// ── Suite 26: D52 audit rotation (Phase 3, ADR 0008 § 5) ──────────────────
//
// Tests for daily audit rotation:
// - First append after UTC date change triggers rename
// - Concurrent appends during rotation produce exactly one rename
// - External cron tool (bin/olp-audit-rotate.mjs) coexists with in-server
// - Rotated files readable by lib/audit-query.mjs after rotation
import {
appendAuditEvent as appendAuditEventS26,
_maybeRotateAudit,
getAuditRotateCount,
__resetAuditRotateState,
__setLastSeenUtcDateForTesting,
} from './lib/audit.mjs';
import { runCli as runAuditRotateCli } from './bin/olp-audit-rotate.mjs';
describe('Suite 26 — D52 audit rotation (Phase 3, ADR 0008 § 5)', () => {
function writeAuditFile(tmp, filename, events) {
mkdirSync(_pathJoinForSetup(tmp, 'logs'), { recursive: true, mode: 0o700 });
const path = _pathJoinForSetup(tmp, 'logs', filename);
const lines = events.map(ev => JSON.stringify(ev)).join('\n') + '\n';
fsWriteFileSyncForS23(path, lines, { mode: 0o600 });
}
function makeEvent(ts) {
return {
ts,
key_id: 'k1',
owner_tier: 'owner',
method: 'POST',
path: '/v1/chat/completions',
provider: 'anthropic',
model: 'claude-sonnet-4-6',
status_code: 200,
latency_ms: 100,
cache_status: 'miss',
fallback_hops: 0,
tried_providers: ['anthropic'],
};
}
describe('26a — _maybeRotateAudit', () => {
let TMP;
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26a-')); });
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
it('26a-1: no live file → no rotation, returns { rotated: false }', () => {
__resetAuditRotateState();
const r = _maybeRotateAudit({ olpHome: TMP });
assert.equal(r.rotated, false);
});
it('26a-2: live file already on today → no rotation', () => {
__resetAuditRotateState();
const today = new Date().toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${today}T10:00:00Z`)]);
const r = _maybeRotateAudit({ olpHome: TMP });
assert.equal(r.rotated, false);
});
it('26a-3: live file holds yesterday\'s events → rotated to audit-YYYY-MM-DD.ndjson', () => {
__resetAuditRotateState();
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
rmSync(livePath, { force: true });
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T15:00:00Z`)]);
const r = _maybeRotateAudit({ olpHome: TMP });
assert.equal(r.rotated, true);
assert.equal(r.dateUsed, yesterday);
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
assert.ok(fsExistsSync(rotatedPath));
assert.ok(!fsExistsSync(livePath));
assert.equal(getAuditRotateCount(), 1);
});
it('26a-4: idempotent — second call after rotation is a no-op', () => {
const r = _maybeRotateAudit({ olpHome: TMP });
assert.equal(r.rotated, false);
assert.equal(getAuditRotateCount(), 1);
});
it('26a-5: rotation target already exists → skip + warn (cron-race safety)', () => {
__resetAuditRotateState();
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
rmSync(livePath, { force: true });
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
writeAuditFile(TMP, `audit-${yesterday}.ndjson`, [makeEvent(`${yesterday}T08:00:00Z`)]);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T20:00:00Z`)]);
let warned = false;
const r = _maybeRotateAudit({
olpHome: TMP,
logEvent: (level, ev) => { if (level === 'warn' && ev === 'audit_rotate_target_exists') warned = true; },
});
assert.equal(r.rotated, false);
assert.ok(warned, 'must warn when rotation target already exists');
assert.ok(fsExistsSync(livePath));
});
});
describe('26b — appendAuditEvent triggers rotation on date change', () => {
let TMP;
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26b-')); });
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
it('26b-1: appending past a UTC date change triggers sync rotation + append lands in new file', () => {
__resetAuditRotateState();
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
rmSync(livePath, { force: true });
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
__setLastSeenUtcDateForTesting(yesterday);
const todayTs = new Date().toISOString();
appendAuditEventS26(makeEvent(todayTs), { olpHome: TMP });
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
assert.ok(fsExistsSync(rotatedPath), `yesterday's rotated file must exist (${rotatedPath})`);
assert.ok(fsExistsSync(livePath), 'live file must exist (with today\'s new event)');
const liveContent = fsReadFileSync(livePath, 'utf-8');
assert.ok(liveContent.includes(todayTs), 'live file must contain today\'s appended event');
assert.ok(!liveContent.includes(`${yesterday}T10:00:00Z`), 'live file must NOT contain yesterday\'s content (it rotated)');
assert.equal(getAuditRotateCount(), 1);
});
});
describe('26c — concurrent appends during rotation', () => {
let TMP;
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26c-')); });
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
it('26c-1: N sequential appendAuditEvent during date change → exactly 1 rotation + all events land in live', () => {
__resetAuditRotateState();
const livePath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
rmSync(livePath, { force: true });
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
__setLastSeenUtcDateForTesting(yesterday);
// 10 sync appends in quick succession. The first triggers rotation
// (date change detected); subsequent appends short-circuit the date
// check because _lastSeenUtcDate was updated inside the first call's
// _maybeRotateAudit. Result: exactly 1 rotation + all 10 events in
// the new live file.
const todayTs = new Date().toISOString();
for (let i = 0; i < 10; i++) {
appendAuditEventS26(makeEvent(todayTs), { olpHome: TMP });
}
assert.equal(getAuditRotateCount(), 1, 'exactly 1 rotation despite 10 sequential appends past date change');
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
assert.ok(fsExistsSync(rotatedPath));
const liveContent = fsReadFileSync(livePath, 'utf-8');
const liveLines = liveContent.split('\n').filter(Boolean);
assert.equal(liveLines.length, 10, 'live file has all 10 appended events');
});
});
describe('26d — bin/olp-audit-rotate.mjs CLI', () => {
let TMP;
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26d-')); });
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
it('26d-1: --help → exit 0 with usage', async () => {
let out = '';
const code = await runAuditRotateCli(['--help'], { out: s => { out += s; }, err: () => {} });
assert.equal(code, 0);
assert.match(out, /OLP audit rotation cron tool/);
});
it('26d-2: no live file → exit 0 "no rotation needed"', async () => {
__resetAuditRotateState();
let out = '';
const code = await runAuditRotateCli([`--olp-home=${TMP}`], { out: s => { out += s; }, err: () => {} });
assert.equal(code, 0);
assert.match(out, /No rotation needed/);
});
it('26d-3: live file with yesterday events → CLI rotates + exit 0', async () => {
__resetAuditRotateState();
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [makeEvent(`${yesterday}T10:00:00Z`)]);
let out = '';
const code = await runAuditRotateCli([`--olp-home=${TMP}`], { out: s => { out += s; }, err: () => {} });
assert.equal(code, 0);
assert.match(out, /Rotated.*dateUsed=/);
const rotatedPath = _pathJoinForSetup(TMP, 'logs', `audit-${yesterday}.ndjson`);
assert.ok(fsExistsSync(rotatedPath));
});
it('26d-4: unknown flag → exit 1', async () => {
let err = '';
const code = await runAuditRotateCli([`--olp-home=${TMP}`, '--unknown'], {
out: () => {}, err: s => { err += s; },
});
assert.equal(code, 1);
assert.match(err, /unknown flag --unknown/);
});
});
describe('26e — rotated files queryable via lib/audit-query.mjs', () => {
let TMP;
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-26e-')); });
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetAuditRotateState(); });
it('26e-1: after rotation, discoverAuditFiles + readAuditWindow see both live + rotated', () => {
__resetAuditRotateState();
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
const today = new Date().toISOString().slice(0, 10);
writeAuditFile(TMP, 'audit.ndjson', [
makeEvent(`${yesterday}T10:00:00Z`),
makeEvent(`${yesterday}T11:00:00Z`),
]);
__setLastSeenUtcDateForTesting(yesterday);
// Sync rotation: completes before append returns
appendAuditEventS26(makeEvent(`${today}T05:00:00Z`), { olpHome: TMP });
const files = discoverAuditFiles({ olpHome: TMP });
assert.ok(files.has('live'), 'live file present');
assert.ok(files.has(yesterday), `rotated file for ${yesterday} present`);
const startMs = Date.parse(`${yesterday}T00:00:00Z`);
const endMs = Date.parse(`${today}T23:59:59Z`);
const events = [...readAuditWindow({ startMs, endMs, olpHome: TMP })];
assert.equal(events.length, 3, 'cross-file read yields yesterday(2) + today(1) = 3 events');
});
});
});