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
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* bin/olp-audit-rotate.mjs — External audit rotation cron tool (Phase 3 / D52)
*
* Authority: ADR 0008 § 5.2 (external cron alternative to in-server first-
* append-after-UTC-midnight trigger).
*
* Use case: operators who want exact-at-UTC-midnight rotation rather than
* "first request after midnight". Invoke from a host cron / launchd job.
*
* Idempotent + safe to run alongside the in-server check (both detect the
* date condition; whichever fires first does the rename; the other no-ops).
*
* Usage:
* olp-audit-rotate [--olp-home=<path>]
*
* Exit codes:
* 0 = success (rotation performed OR no rotation needed)
* 1 = bad usage (unknown flag)
* 2 = rotation attempted + failed (e.g., EACCES)
*
* Example cron line (UTC midnight):
* 1 0 * * * /usr/local/bin/node /path/to/bin/olp-audit-rotate.mjs >> /var/log/olp-audit-rotate.log 2>&1
*/
import { _maybeRotateAudit } from '../lib/audit.mjs';
function parseArgv(argv) {
const flags = {};
for (const arg of argv) {
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq > 0) flags[arg.slice(2, eq)] = arg.slice(eq + 1);
else flags[arg.slice(2)] = true;
}
}
return flags;
}
const USAGE = `OLP audit rotation cron tool
Usage:
olp-audit-rotate [--olp-home=<path>]
Triggers a rotation check: if the live audit.ndjson holds events from a
past UTC date, rename it to audit-YYYY-MM-DD.ndjson. Idempotent; safe to
run alongside the in-server first-append-after-UTC-midnight trigger.
Authority: ADR 0008 § 5.2.`;
export async function runCli(argv, opts = {}) {
const ioOut = opts.out ?? (s => process.stdout.write(s));
const ioErr = opts.err ?? (s => process.stderr.write(s));
if (argv.includes('--help') || argv.includes('-h')) {
ioOut(USAGE + '\n');
return 0;
}
const flags = parseArgv(argv);
const allowed = new Set(['olp-home', 'help', 'h']);
for (const k of Object.keys(flags)) {
if (!allowed.has(k)) {
ioErr(`Error: unknown flag --${k}\n${USAGE}\n`);
return 1;
}
}
const olpHome = typeof flags['olp-home'] === 'string' ? flags['olp-home'] : undefined;
try {
// _maybeRotateAudit is synchronous at v0.3.0 (D52) — rotation must
// complete BEFORE the next append so no event straddles the boundary.
const result = _maybeRotateAudit({ olpHome });
if (result.rotated) {
ioOut(`Rotated ${result.fromPath} -> ${result.toPath} (dateUsed=${result.dateUsed}).\n`);
} else {
ioOut('No rotation needed (live audit is current or absent).\n');
}
return 0;
} catch (err) {
ioErr(`Error: rotation failed: ${err?.message ?? err}\n`);
return 2;
}
}
// Main guard
const isMain = (() => {
try { return import.meta.url === `file://${process.argv[1]}`; }
catch { return false; }
})();
if (isMain) {
runCli(process.argv.slice(2)).then(code => process.exit(code));
}