The audit log in this system has been growing in completeness for weeks. The original version recorded HTTP-accepted writes — the API said yes, we logged it. The expansion that mattered most was the one in 4.5.39: writes that the API rejected — HTTP 200 with an errors body — were now logged too, with outcome: "rejected" and the API's error message preserved. That release closed the gap where an attorney's intent could disappear because Merus said no.
Then the in-flight entry shipped: every write logs its intent BEFORE the fetch fires, so even a process death between the request leaving the wire and the response arriving still leaves a record of what was attempted. The trail covered network truncation, OS signals, anything that killed the process between request and response.
What was still missing — and this took surprisingly long to notice — was the third category: writes that never made it to Merus at all because a code-level guard refused them. Task-delete guard. No-binding guard. Applicant-mismatch guard. Filename-format guard. PDF-hash-drift guard. Each of these is a deliberate choice we ship: if the attorney tries to delete a task instead of completing it, refuse. If the attorney tries to file a PDF to a case whose applicant doesn't match what aai-bind extracted from the PDF, refuse. If the filename doesn't include the bound applicant's last name, refuse. Twelve different refusal paths, each one a deliberate piece of safety.
None of them were logged. They printed clear, helpful messages to the attorney's terminal — "BINDING GUARD — REFUSED (applicant mismatch)" with the bound name, the case applicant, and the override instructions — but the audit log saw nothing. The trail showed what was done; it did not show what was attempted and refused.
The reason this gap matters is the same reason every other audit-log expansion matters. The attorney's intent is part of the record. Whether the answer was yes, no-from-the-API, or no-from-the-guard, the attempt happened. The attorney chose to issue it. Months later, in a deposition or a malpractice review or an internal audit, "what did your tool actually do" requires both halves: what succeeded, and what was prevented. A trail that only shows successes is a trail that pretends mistakes were never even tried — and that's not the truth.
The fix that was about to be silently wrong
Writing the helper was straightforward. A small function that took the endpoint, the field list, the guard name, and a reason string. It produced an audit entry with outcome: "guard_refused" and the specific guard that fired. Each refusal site got a one-line call before the existing process.exit(3).
The first guard wired was the task-delete one. Run merus-fetch /tasks/del/12345 against a non-override-token environment, see the refusal message on stderr, check the audit log. The audit log did not have the entry.
The other guards were wired the same way. They worked. The applicant-mismatch refusal showed up in the audit log with the bound applicant, the case applicant, and the reason. The filename-format guard showed up. The no-binding guard showed up. The task-delete one — the very first one I'd wired — did not.
The two paths were structurally identical. Same helper function. Same call shape. Same try/catch around the file-append inside the helper. The only difference was where in the file the call sites lived. The task-delete guard fires early, around line 329 of bin/merus-fetch.mjs — before the GET/POST branch, before most of the file's logic runs. The other guards fire later, after all the constants are set up.
The helper function used AUDIT_LOG_PATH, a const declared at line 470. JavaScript hoists function declarations — the helper itself was callable from line 329. But const has temporal-dead-zone semantics: referencing a const before its declaration line is a ReferenceError, not undefined. When the task-delete guard at line 329 invoked the helper, the helper hit the unset AUDIT_LOG_PATH and threw. The throw was caught by the helper's own try/catch, which was there for exactly the right reason — "audit logging should never crash the app" — but which also swallowed the only signal that the audit write was failing.
The audit-log gap I was trying to fix was being recreated by the fix itself. Every other guard's audit entry was appearing because those guards fire after the const initializes. The task-delete guard's audit entry was silently nonexistent because that guard fires before the const exists.
The fix was to introduce an early-stage helper with its own const declarations placed before the task-delete guard. Functionally identical code; just in scope at the time the early guard fires. After the change, merus-fetch /tasks/del/12345 produces an audit entry with guard: "task_delete" and the refusal reason, alongside the stderr message.
The lesson is one I'm going to keep with me. const hoisting is not the same as function hoisting. The pattern "define helper near the data it touches" is correct, but if a helper that touches late-declared data gets called from early code, the call will fail in a way that is invisible by design — the try/catch that protects callers from audit-log failures is also the try/catch that hides the audit-log helper failing for the wrong reason.
Hoisting is a feature you can use accidentally. The bug was that I had reached for it without noticing, and the protective try/catch made the accident invisible. The discipline now: when a helper depends on constants, declare the constants in the same physical region as the helper, and audit (literally — read the file top to bottom) every call site to confirm it lives after the declarations.
Closing the rest of the sites
After the task-delete fix, the remaining 11 refusal paths were straightforward. Each one got an auditGuardRefusal() call before its process.exit(3), with a reason string carrying the specific state of the refusal: which upload, which case, which filename, which proposed tags. The reason strings are deliberately verbose. At audit-review time, a year from now, the question "why was this refused" should have a specific answer in the trail, not a generic guard_refused.
What didn't get wired this round: the PAUSE paths (medium-certainty tag mismatches). PAUSE is a different exit code (4 instead of 3) and a different semantic — "attorney confirmation needed" rather than "absolutely refused." Whether PAUSE belongs in the audit log is a design question, not a bug. A flagged intent is still an intent. But PAUSE writes that the attorney confirms become accepted writes, which would show up in the audit log under their own outcome. PAUSE writes that the attorney does not confirm... never produce another write. Logging the PAUSE itself would capture the moment the system stopped to ask. The next pass.
What the audit log now contains
Every POST that leaves the local process produces at least one audit entry: in_flight at request start. If the response arrives, a second entry: accepted or rejected with the HTTP status and any API error. If a guard refuses the write before the request fires, a single entry: guard_refused with the guard name and reason.
The trail is now closed under every category of attempt. Accepted, rejected by API, refused by guard, killed mid-flight. Any future operation that ends without a matching audit pair is a candidate for reconciliation — the in-flight without a follow-up is an orphan that may have reached Merus, and the audit-review process can chase that down by inspecting Merus directly. Nothing important silently disappears anymore.
The version on the install URL is 4.5.55. The work continues at the pace of one verified release at a time, and the new release is small enough that you could pass it past in a changelog and think nothing important happened. The pattern of these releases is the part that matters — finding the thing you weren't logging, finding the thing about the fix that was about to make it silently wrong, and shipping both in a single verified release with the audit trail to prove the property holds.