The first line of the header comment in lib/audit.mjs:
The audit must NEVER fail-block the caller’s operation.
The motivation is clear. The audit log records every Merus write — accepted, rejected, refused-by-guard — for compliance. If the audit itself becomes a blocking failure, you’ve traded a compliance feature for an availability bug. A user’s attempt to file a document shouldn’t die because their disk is full.
The implementation:
function withAuditLock(fn) {
ensureDir();
try {
return _withFileLock(AUDIT_LOCK, () => fn(true), { timeoutMs: 30000 });
} catch (e) {
if (typeof e.message === 'string' && e.message.includes('timed out')) {
return fn(false); // best-effort unsynchronized append
}
throw e; // ← here
}
}
The catch handles one specific failure: lock timeout. Every other failure mode from the lock layer — EACCES (permission denied), ENOSPC (no space left on device), EROFS (read-only filesystem), even simple ENOENT if the directory disappeared — falls through to throw e. The exception propagates up to the audit caller, which is usually merus-fetch in the middle of writing a real record.
The reproduction
Stress-testing the audit module against failure modes:
$ chmod 500 ~/.aaicase
$ node -e "import('./lib/audit.mjs').then(m => m.appendAudit({foo:'bar'}))"
node:fs:554
return binding.open(
^
Error: EACCES: permission denied, open '~/.aaicase/audit.log.lock'
at openSync (node:fs:554:18)
at withFileLock (lib/file-lock.mjs:77:12)
at withAuditLock (lib/audit.mjs:68:12)
at Module.appendAudit (lib/audit.mjs:170:3)
EACCES propagates out of withFileLock, through withAuditLock, through appendAudit, and into the caller — which in production is merus-fetch in the middle of recording an actual write. The user’s Merus operation dies, not from API rejection, but from audit logging crashing.
How does a user end up with a 500-permission directory?
The standard answer is “they don’t” — and on a freshly-installed dev machine, that’s right. But real users on real machines aren’t fresh installs. Sources of permission drift:
- chmod -R applied to the wrong directory. The classic. User runs
chmod -R 755 ~/something, fat-fingers the path, hits~/.aaicase. - Restored from backup with wrong owner. Time Machine, rsync, Dropbox — any cross-machine restore can land with the wrong uid/gid.
- Network mount remounted read-only. Home directories on NFS/SMB can transition to read-only when the share has issues.
- Quota exceeded. Some filesystems return EACCES instead of ENOSPC when the user is over quota.
- SELinux / AppArmor. Linux MAC policies can deny lock-file creation even when DAC permissions look fine.
None of these are exotic. Any one of them turns every Merus write into a crash, with an error message that points at audit.log.lock rather than the actual cause.
The fix
function withAuditLock(fn) {
try {
ensureDir();
} catch {
// Even mkdir failed (HOME on read-only filesystem). Degrade to
// fn(false) so callers proceed.
return fn(false);
}
try {
return _withFileLock(AUDIT_LOCK, () => fn(true), { timeoutMs: 30000 });
} catch (e) {
// ANY error from the lock layer — timeout, EACCES, ENOSPC,
// platform-specific. Degrade to unsynchronized append.
return fn(false);
}
}
The change is small. Catch the broader exception, degrade to fn(false) (which the inner audit-write loop interprets as “best effort, no lock, the line may be lost or interleaved”). The inner append itself is wrapped in another try/catch that catches any actual file-write failure, so even an unsynchronized append fails safely.
The cost: in the EACCES case, the audit entry is lost. That’s bad. The benefit: the user’s legitimate Merus write succeeds. That’s better than “both fail and the user thinks Merus is broken.”
The verification
Three tests:
- Read-only directory:
chmod 500 ~/.aaicase, thenappendAudit(). Pre-fix: throws EACCES, crashes caller. Post-fix: returns cleanly. - Happy path: two entries written, mode 0o600, ISO timestamps.
- Concurrent stress: 5 processes × 20 writes = 100 entries. All present, no malformed lines.
The first test was the one that justified the change. The other two confirm the fix doesn’t regress.
The meta-fix
While I was in the file, two other small things:
- Removed an unused
STALE_LOCK_MS = 10000constant that was declared but never referenced. Dead code that suggested behavior the implementation didn’t actually have. - Corrected a comment that claimed “PID-liveness + 10s mtime fallback” for stale-lock detection. The mtime fallback was removed in v4.5.94 because it false-broke held locks under contention. The comment in audit.mjs was left behind.
The takeaway: when a header comment makes a promise (“never fail-block”), the implementation has to honor it across all failure modes, not just the one the original author was thinking about. EACCES isn’t exotic. ENOSPC isn’t exotic. A user on a real machine hits these. The defense has to be at the broadest plausible failure surface — catch (e), not catch (e) { if (e.message.includes('timed out')) ... }.