The misfile guards we built earlier this week prevent a specific class of error: a write to MerusCase that goes to the wrong case, with the wrong filename, with the wrong tag. The guards work. They refuse those writes at the API layer regardless of what the model proposes. That property is real and verified.

The guards don't, however, cover a different class of problem. The system can accept the attorney's input, do something with it, and report back in a way that obscures what actually happened. The write completes correctly. The audit log records the right thing. But the attorney's understanding of what happened diverges from what happened.

This isn't a safety bug in the sense the misfile guards address. It's an interface bug. The system tells the attorney one thing while doing another, or tells the attorney part of the truth while quietly handling the rest. Three of these shipped fixes over the last day, and they share enough shape to be worth writing about together.

The parser that quietly dropped half the instruction

The task walker has an UPDATE option that lets the attorney describe changes in plain English. Type "priority high" and the task's priority changes. Type "due 2026-06-15, priority high, description: follow up with adjuster" and three fields change at once. The flow shows a preview before writing — the attorney sees what's about to happen, types yes, the write goes through.

What the original parser did not do was tell the attorney about parts of their input it couldn't understand. Type "priority high and reassign to the paralegal" and the parser would extract the priority, the preview would show "Priority: HIGH", and the attorney would approve. The "reassign to the paralegal" portion was silently discarded. The attorney's mental model after approval: priority changed, reassignment happened. The actual outcome: priority changed, reassignment didn't.

The fix is mechanical. The parser now tracks which character ranges of the input it consumed during matching. Whatever's left over after parsing is identified and shown in the preview as text the system did not understand and will ignore. The preview now looks like:

PROPOSED UPDATE:
  ✓ Priority: HIGH

⚠ NOT PARSED — the following text was not understood and will be IGNORED:
  "and reassign to the paralegal"
  Looks like you want to reassign — that's a separate action.
  Cancel this update and press R to reassign.

  If this text was meaningful, type N to cancel and re-enter.

Approve? (yes / no):

The attorney now has the option of either approving (knowing exactly what's about to happen) or canceling. The reassignment instruction is recognized as something the system can't apply through this code path, but the system also recognizes what kind of action the attorney probably meant and tells them how to do it correctly.

The detection logic is the boring part. The interesting part is what was wrong before: the parser took valid input from the attorney, partially fulfilled it, and gave back a preview that suggested the partial fulfillment was the whole fulfillment. The system was not lying — the preview accurately listed what would be written. But the attorney's working assumption — "all my words will be acted on" — was wrong, and the system didn't surface the discrepancy.

The picker that couldn't tell two people apart

Reassign in the task walker lists the firm directory and lets the attorney pick a new owner for a task. The original picker showed name and a numeric user_id in faint gray:

1. Alice Smith   (425)
2. Alice Jones   (19)  (current)
3. Bob Smith     (51)

This works as long as no two staff share a first name. The moment they do — and most firms have at least one such collision — the picker breaks in a quiet way. The attorney types "alice" and gets two matches. The picker re-lists them with names only. The attorney picks number 1. Was that Alice Smith or Alice Jones? Probably the one they meant. Not certainly.

The user_id was technically on screen. It was hard to read in a fast-moving terminal, formatted as parenthetical gray text after the name. In practice it didn't get used for disambiguation because nobody thinks of staff in terms of their internal database IDs.

The fix shows email alongside name. Email is the disambiguator firms already use — every staff member has a unique email, and the attorney usually knows them by it. The picker rows now look like:

1. Alice Smith            alice.s@firm.com         (id 425)
2. Alice Jones            alicej@firm.com          (id 19)   (current)
3. Bob Smith              bob@firm.com             (id 51)

The match logic now searches both name and email. The attorney can type "alice.s" to narrow to Alice Smith specifically, or "alicej" for Alice Jones. The disambiguating string is the one the firm already uses in their head.

While verifying this fix, we discovered a separate bug we hadn't known about. The user directory endpoint in MerusCase returns indexed fields (the first field is the first name, the second is the last name, the third is the email) rather than the named fields ("first_name", "last_name") the original code was reading. The result: the original code was returning empty strings for every user's name and falling back to displaying the numeric id alone. We had been shipping a picker that showed nothing but IDs and had not noticed because we'd been using the admin token, whose record happened to have a "name" field populated separately.

The fix reads the indexed fields first with the named fields as fallback. It also normalizes ALL CAPS storage (which MerusCase uses) to title case for display, because reading a directory of "SAMPLE ATTORNEY / SAMPLE ASSOCIATE / SAMPLE PARALEGAL" in a terminal is meaningfully harder than reading "Sample Managing Attorney / Sample Attorney / Sample Paralegal." Small differences, but small differences in a directory you scan dozens of times a day add up.

The multi-step write that left half-completed work behind

Filing an upload in MerusCase isn't one write. It's a sequence: rename the file and assign it to a case, then create an activity entry on the case timeline, then create a follow-up task for the right attorney, sometimes also create a calendar event. Each of these is a separate REST call. If the second call fails, the first call has already happened. The file is on the case but there's no activity describing what it is. If the third call fails, the file and the activity are both there but there's no task — the deadline that the document creates is invisible.

MerusCase doesn't support transactions across these endpoints. You can't wrap them in a BEGIN/COMMIT block. The best you can do is invoke compensating writes — recipes that undo each forward write — when something later in the sequence fails. This isn't true atomicity. It's best-effort cleanup, with the honest acknowledgment that any compensating write can itself fail, and at that point the attorney has to look at the audit log and clean up by hand.

The runner we shipped accepts a list of steps. Each step has a name, a forward write function, and an optional undo function:

const sequence = [
  {
    name: 'file-upload',
    write: async () => { ... },        // rename + assign upload
    undo:  async (result) => { ... },  // un-assign upload
  },
  {
    name: 'create-activity',
    write: async () => { ... },
    undo:  async (result) => { ... },  // soft-delete activity
  },
  {
    name: 'create-task',
    write: async () => { ... },
    undo:  async (result) => { ... },  // soft-delete task
  },
];
const result = await runSequence(sequence, { label: 'file-mail-upload-N' });

If all steps succeed, the runner returns and the attorney sees a clean write. If step 2 fails, step 1's undo is invoked, the result is reported as "rolled back cleanly," and the attorney is told the filing didn't go through. If step 3 fails, step 2's undo runs first, then step 1's undo. If any undo itself fails — say the rollback HTTP call gets the same network error that broke the forward call — the runner records which undo failed and surfaces it specifically.

Every sequence run writes one structured audit entry. A companion command-line tool reads that log and shows recent sequences with status codes: OK (cleanly completed), ROLL (failed but cleanly rolled back), PART (failed and some steps had no undo recipe — the attorney needs to manually clean those up), UERR (failed and some undo calls also errored).

The audit log is critical here because automatic rollback is only one layer of recovery. The other layer is the attorney being able to see exactly what happened. If an automatic rollback left part of the work hanging — because that step had no undo, or because the undo itself failed — the attorney needs to know exactly which step needs manual attention. The structured log entry tells them.

One deliberate choice: the rollback tool does not execute writes itself. It only displays history. The reasoning is that a sequence which has been live in MerusCase for some time may have downstream dependencies that automatic rollback could compound. An activity created by step 2 might be referenced by another activity created later, by someone else, outside this sequence. Rolling back step 2 in isolation could leave dangling references. The attorney is the right entity to look at the situation, decide what's safe to reverse, and do it deliberately through the normal write path. The automatic rollback we built only runs in-flight, before the sequence completes — that's the moment when atomic undo is genuinely safe.

The pattern underneath

Three fixes, three different bugs, one shared pattern. In each case the original implementation took attorney input, did something with it, and reported back. In each case the report was technically true but operationally misleading.

The parser preview accurately showed what would be written, but omitted what wouldn't. The picker accurately identified each staff member, but didn't surface the data the attorney would need to tell them apart. The filing sequence executed each write, but failure of any step left earlier writes in place without telling the attorney what cleanup was needed.

The fixes are interface fixes. They don't change what the system does in the happy path — successful updates still update, successful reassigns still reassign, successful filings still file. They change what the system says about what it did, especially in cases where the attorney's intuition about the outcome might diverge from the actual outcome.

This is a category of work that is easy to skip. None of these bugs would have produced a wrong write to MerusCase. The misfile guards were already preventing that. These bugs would have produced correct writes plus an attorney who didn't quite know what had happened. The harm is slower — work has to be redone, audit trails have to be reconstructed, the attorney's trust in their own memory of what they did erodes over time.

The discipline we've been trying to keep is that interface fidelity matters as much as state correctness. The system should not just do the right thing; it should tell the attorney accurately what it did, especially when what it did diverges from what the attorney might have assumed.

What's verified

Each of these shipped with a verification suite that ran before publish:

The CHANGELOG records each fix with the verification evidence. The pattern we've been keeping: no release ships without verification, no claim is stronger than what was verified.

What's next

This closes the last item from the structured audit we ran after the first guard layer shipped. Eleven items identified, eleven items fixed, each with its own verification. The original misfile incident that motivated all of this is now bounded by code refusal at the API layer, multi-source applicant extraction in the binding, signed override tokens for the rare legitimate bypass, atomic rollback for partial failures, and interface fidelity in how the attorney is told what happened.

Open work surfaced during the audit but not yet addressed: the mail processing skill still executes its multi-step writes directly through the agent loop rather than through the new rollback runner, so the rollback property is available but not yet wired into the production filing flow. The Windows compatibility script ships in the tarball but hasn't been run against an actual Windows machine yet — only the audited code paths have been validated, not the live integration. An inactive-user lookup gap surfaced during verification of an earlier fix and is still in the backlog.

Each of these will get the same treatment: identified, fixed, verified, recorded. The work continues at the pace of one fix per release, one release per cycle, with the audit trail growing in lockstep.

None of this is glamorous. It is the work of being an AI tool that operates safely inside a real law firm.