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:

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:

  1. Read-only directory: chmod 500 ~/.aaicase, then appendAudit(). Pre-fix: throws EACCES, crashes caller. Post-fix: returns cleanly.
  2. Happy path: two entries written, mode 0o600, ISO timestamps.
  3. 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:

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')) ... }.