A workers' compensation case management tool has several small files on disk that hold its security-critical state. None of them are large. None of them are accessed by anything other than the tool itself. They all live in ~/.aaicase/queue/ with owner-only permissions, inside an owner-only directory. They are private to the attorney's machine.
Three of them matter for this story:
- bindings.json — the upload bindings. For each upload ID the attorney has bound, what applicant name was extracted from the PDF, what tags were suggested, what SHA-256 the PDF had when extracted.
- message-bindings.json — the equivalent for email messages. For each message ID, what activity-type tag was classified.
- redeemed-overrides.json — the record of every single-use override token that has been redeemed. Keyed by token fingerprint, with the kind, parameters, redemption timestamp, and PID of the redeemer.
All three are read on every write that the system is asked to perform. The upload-bindings file authorizes filings: the binding guard checks that the case the attorney is filing to has an applicant matching what was extracted from the PDF. The message-bindings file authorizes email-tag writes the same way. The redemption file enforces single-use — when an override token is presented, it's hashed, the file is checked, and if the fingerprint is already in there, the token is refused as already-used.
All three had the same code shape for reading: a readFileSync wrapped in a try/catch, with the catch returning an empty object. If the file is missing, return empty. If the file is corrupt, return empty. The pattern looks defensive — fail gracefully, don't crash. For two of these three files, the pattern was correct. For the third, it was very nearly catastrophic.
The difference between the three files
The upload bindings and the message bindings are both presence-authorizing. A write proceeds because a corresponding entry exists in the file. If the file is empty (or unreadable, or absent), no entry exists for any upload, and the binding guard refuses the write. The attorney sees "no binding" errors and has to run aai-bind to recreate the binding before they can file. That's the right behavior. An attorney who can't verify their bindings shouldn't be able to file PDFs; the worst that happens is they have to re-run the extraction step.
"Return empty and keep going" composes correctly here. Empty means no bindings exist. No bindings means every write is refused. Refusal is the safe state.
The redemption file is absence-authorizing. The single-use rule is: if a token's fingerprint is NOT in the file, the redemption is allowed, and the fingerprint is added. The presence of an entry is the signal that the token has been used and must be refused.
If readRedeemed() returns empty on a parse failure, every token's fingerprint is "not in the file." Every token that has ever been redeemed appears un-redeemed. The single-use property is not just weakened — it is reversed. An attorney (or anyone with access to the attorney's terminal session) could re-present a previously-used override token and it would be accepted, every time, until the corrupted file was repaired.
"Return empty and keep going" composed incorrectly here. Empty means no fingerprints are redeemed. No fingerprints redeemed means every token gets a free pass. Every override that has ever been issued — and every override is meant to authorize exactly one write — could now authorize any number of writes.
What "fail-closed" means in this context
The threat model for the redemption file is filesystem corruption, not external tampering. The file is owner-only, inside an owner-only directory. An attacker who could write to it could also write to the bindings file, could also write to the audit log, could also use the attorney's local Merus token directly — they own the machine. The defense against external attack is the operating system's file permissions, not the contents of these JSON files.
What the JSON file holds against is internal corruption: a disk error, a crash mid-write, an abrupt shutdown that truncates the file partway. None of those are deliberate attacks. All of them are real-world events on attorney laptops that get put to sleep mid-session, on machines that share storage with a dozen other applications, on filesystems that occasionally lose bytes.
For the bindings files, the fail-soft behavior is right. Corruption produces over-refusal — the attorney has to re-run aai-bind, lose a few minutes of work, but no incorrect state escapes to Merus. For the redemption file, the fail-soft behavior is wrong. Corruption produces under-refusal — overrides that should have been single-use become reusable, and the corruption may not be visible to the attorney at all (the file is still readable as a string, just not parseable as JSON).
The fix was small. readRedeemed() on parse failure now throws instead of swallowing. The throw propagates up to the existing try/catch in checkOverride(), where the catch reports redemption_write_failed and refuses the override. The error message tells the attorney how to safely clear the file (losing redemption history) if they need to recover from the corruption.
Single-use enforcement is now fail-closed under filesystem damage. A corrupted file refuses all overrides until restored. A merely missing file (which is the legitimate first-run state) still returns empty — but the empty-string and "doesn't exist" cases are now distinguished from "exists but won't parse," which was the only ambiguous case.
The bindings file fix
The bindings files don't need the fail-closed treatment — their fail-soft behavior is already safe. But the silent-empty-return had a different problem: when the file gets corrupted, the attorney has no way to know. They see every write suddenly fail with "no binding," they re-run aai-bind, the new binding writes succeed (because the file gets overwritten with valid JSON), and the moment of corruption is invisible. If the file is large (months of bindings), the attorney has just lost months of state without realizing it.
The fix here is small too. readBindings() on parse failure now prints a stderr warning identifying the file and the parse error before returning empty. The fail-soft behavior is preserved — guards still refuse — but the attorney now knows what's happening. The same fix shipped for readMessageBindings() a few releases later, after a spot-check noticed the message-bindings file had been left with the original silent-swallow pattern.
The pattern
"Return empty and keep going" is one of those code patterns that looks identical no matter what the data is for. The function signature gives no hint about whether the caller's reaction to an empty result is "refuse" or "allow." The composition only makes sense one layer up, where the empty value gets interpreted against the system's policy.
The audit lesson is to ask, for every fail-soft empty return: what does empty mean to the caller, and is "what empty means" the safe answer when the file was actually unreadable for some other reason? If empty means "no work to do" or "no items present," fail-soft is safe. If empty means "no enforcement state, so everything is permitted," fail-soft is dangerous — fail-closed is correct.
The system now has three files with three behaviors. Upload bindings: fail-soft with warning. Message bindings: fail-soft with warning. Redemption file: fail-closed with recovery instructions. Each file's behavior matches what its emptiness would mean to the guards above it.
The releases that landed this work were 4.5.49 (zero-byte download refusal), 4.5.50 (fail-closed redemption), and 4.5.56 (parity for message bindings). The version on the install URL is 4.5.56. The work continues at the pace of one verified release at a time.