Written for California Workers' Compensation attorneys. No hype. No buzzwords. Just the real problems and how to solve them.
The contact lookup returned zero matches. The phone was sitting in the database. The skill searched the only endpoint it knew about, and that endpoint didn't carry phone fields. The fix was a second search phase that walks every case's deep view record — slower, but it finds things the index can't see. This post is about the gap, the walk, and why there is no cache.
The settlement-value skill was emitting body_part_codes:[21,22,26,27,25,54] for a real case. Six numbers. The attorney can read the rest of the line, but the body-part codes are opaque. Merus stores body parts as numeric IDs and exposes no catalog endpoint that resolves them to labels. The fix wasn't a vendor API call. It was 222 cases, 234 records, statistical lift, and a cross-reference against the standard form the codes were already aligned to.
When an attorney asks AAI to draft a TD demand letter, the assistant looks up LC 4650, LC 5814, and LC 4453 before writing a word — the lookups hit a local JSON index of 2,631 Labor Code sections that ships with the package. The citations come back accurate by construction, with verbatim quotes the carrier can't quibble with. This post covers how that actually works in the chair, and — equally important — six specific failure modes where it doesn't, reproduced against the tool that ships today.
The read path checked where a redirect was sending it and refused an unexpected host. The write path — the higher-stakes one — followed redirects with no check at all. Neither leaked the auth token, so this was not a credential leak. But a guard on reads and no guard on writes is backwards, and the reasoning that fixed it is the oldest move in the book: if the lighter case is guarded, the heavier case all the more so.
A reading discipline — treat every word as deliberate, infer from precise wording and from what is conspicuously left unsaid — applied to a working codebase for a full day. Nineteen releases. The standout: statutory deadlines were rendering in UTC on a Pacific machine, pushing the QME-objection and IMR-appeal dates a full day late. Plus a cross-applicant misfile hole, a "no deadline" that really meant "fetch failed," and an override token that authorized more than the operator approved. The system worked yesterday; it has far fewer ways to be silently wrong today.
A user asked a simple question — could downloaded PDFs and tmp files be accumulating somewhere? They could. Audit on the dev machine found 240 stale /tmp/aai-* directories holding 34.6 MB of full case API dumps and downloaded PDFs. The cleanup gap had three layers: skill bash blocks that only ran rm -rf on the happy path, a cache/uploads directory with no TTL, and a /clear-cache command that silently skipped subdirectories.
A user on Windows hit three distinct bugs in one debugging session. Each looked unrelated. The first sent /update to Claude as a query. The second misdiagnosed a Merus 403 as Claude auth needing /login. The third was MSYS/Git Bash silently converting /users/index into C:/Program Files/Git/users/index before it reached the API client. Together they made what should have been a simple update look like a token-permission crisis.
A blog post used real client names to show a brief improvement. Investigating revealed something worse: the npm package itself shipped one firm’s identifying data baked into skill files, the system prompt, and the changelog. Every install carried it. The scrub touched 45+ replacements across 16 files. The follow-up wired a build-time check that prevents regression — and almost immediately caught me re-introducing the data in the very changelog entry describing the scrub.
The header comment in lib/audit.mjs said the audit must never fail-block the caller. The implementation honored that promise for one failure mode (lock timeout). Every other lock-layer error — EACCES on a read-only directory, ENOSPC on a full disk, EROFS on a read-only mount — would throw out of withAuditLock and crash the calling process. Reproducible with chmod 500 ~/.aaicase.
End-to-end testing of aai-undo --show LABEL surfaced a real data-corruption bug. When the same sequence label was used by multiple runs (labels are not unique), the suggested manual-cleanup commands interpolated record IDs from the most-recent artifact — for ALL listed entries. An attorney following the suggestion would have deleted a task from a successful run while the actual orphan from a failed run stayed put.
The architectural rule “PDFs are read natively as vision by the Read tool, never via text extraction” was documented in four files but enforced nowhere. Any future commit could reintroduce pdftotext or pdf-parse and nothing would catch it. A new CI check scans every shipped file for banned imports — and immediately caught a defensive status-line label in the CLI that hinted PDF text extraction was a normal occurrence.
Five document types carry real deadlines and admit multiple legitimate attorney strategies — IMR decisions, UR denials, QME reports, DORs, and Notices of Hearing. The assistant used to auto-propose a generic follow-up task no matter what the attorney actually wanted. Now it stops, presents the strategy options, and waits. The task that gets created matches the chosen plan — appeal, challenge timing, accept, defer, or just file. Fewer noise tasks, better legal judgment attribution.
Adding "Archived" as a status option turned out to require fixing a deeper problem: the assistant had hard-coded case-status IDs from one firm in seven places across the codebase. Every firm in Merus configures their own statuses. The previous behavior would have shown wrong open-case counts, wrong status labels, and the wrong options when settling on any firm with a different setup. The fix: read each firm’s actual status definitions from Merus and use them everywhere.
A demonstration of what the new morning brief surfaces that the old one missed. Three illustrative cases going to formal proceedings this week where the attorney has not spoken to the client in more than six months. One — a doctor cross-examination in 3 days — at 287 days. The kind of thing that ruins testimony, blows credibility with a judge, and shows up in malpractice complaints. This is what changed.
The morning brief skill had been working for months — fetching events, tasks, mail, presenting them as sections. But running it against live firm data exposed six concrete problems: a meaningless prep check, missing event types, unranked overdue tasks, no client-contact signal, no danger score, and fragile date comparisons. The fix transforms the brief from a list of facts into a triage of situations. Top of the new output: a demonstration case showing DR-DEPO in 3 days, no prep task, 287 days since last client contact.
While running an example bash block end-to-end to verify a fix, the script created two ghost tasks in production. Merus had silently accepted literal template placeholders — case_file_id=CASE_ID, date_due=MM/DD/YYYY — and coerced them to 0 and invalid epoch. The write succeeded; the data quality silently degraded. The same accidental run exposed a second bug: the staff lookup pattern used field names that do not exist in the API response, so every lookup based on that pattern has been returning "not found" for who knows how long.
A binary audit for silent-failure patterns surfaced three functions in the binding pipeline that returned empty arrays on every failure path with no operator warning. The empty arrays flowed into the LLM prompt as "catalog unavailable" — and the resulting activity records got filed UNTAGGED. Untagged activities silently degrade case organization, mail rules, and report filters. The fix: every degradation path now prints a WARNING with the specific reason and the API permission to investigate.
The previous release fixed graceful degradation in 13 parallel-fetch skills. A separate class of 15 skills uses single-fetch pipelines — `merus-fetch | node -e ...` — and none of them had `set -o pipefail`. Without it, a failed fetch could exit 0 if the consumer succeeded, silently masking API errors. The worst case captured an empty string into a variable that downstream specs use to write activity records — quietly corrupting case data with empty message IDs.
Per-skill testing surfaced a real bug shape — twelve skills hard-failed with a generic "ERROR" message when any one of their parallel API fetches returned a permission denial. The user got no output and no way to know which endpoint was the problem. The fix family: capture per-endpoint stderr, track per-endpoint success, separate required from optional, log the specific endpoint that failed, gracefully degrade the affected section.
Two more stale lines in per-document-type extraction templates referenced a retired chunked-Agent strategy. Three lines updated, two files touched. The cross-codebase sweep is now clean of every bug shape we found this session: the noise-filter regex, the chunked-PDF strategy references, the directory-existence-as-auth-check. A natural stopping point for the doc-sweep arc.
A foundational instruction file described a PDF reading strategy that had been retired 100 releases ago. The runtime code had been doing the new thing for releases. The agent — which interprets the instruction file as a playbook on every session — had been reading and following the outdated strategy the whole time. The discipline: any doc the runtime reads is part of the codebase and needs the same change-tracking as the source it describes.
A release whose entire point was "fix every copy of this bug" missed five more copies of it. The cause was straightforward: I grepped for the wrong substring. The lesson: when sweeping for same-shape bugs across a codebase, grep for the buggy expression itself, not for usage patterns that happen to wrap it.
Two bugs fixed weeks ago in the binaries still had uncopied copies in the skill files. When the agent runs those skills, the buggy patterns execute verbatim. A fix in one .mjs source does not propagate to a .md skill that contains the same JavaScript pattern in a bash heredoc. The rule: when you fix a copy-pasted bug, grep the entire codebase including markdown.
An installer that wanted to know whether the user was logged in to a CLI tool checked for the existence of a config directory. The directory existed for any prior use of that CLI, even uses that never completed authentication. New users with the directory but no credentials silently skipped the login step and then could not figure out why the dependent tool failed to reach the LLM.
JavaScript URL validation accepts strings with embedded newlines, control characters, and other whitespace — silently collapsing them in the parsed result but not in the original input. A config validator that runs new URL(input) without then using the parsed canonical form passes validation on multi-line input that then flows through to wherever the input is used downstream. For a system prompt that interpolates configured values, the newline becomes a new prompt line.
The same file-lock implementation had been copy-pasted into five modules in this codebase. After the third independent discovery of the same data-loss bug across three different files, I finally extracted it into a shared helper. The fourth and fifth copies turned out to have the same bug — one of which was silently breaking single-use token enforcement.
When the queue lock got fixed in an earlier release, the fix involved dropping a fallback that had been working fine in the audit lock and was assumed to be working fine everywhere else. The same probe pointed at the binding locks found the same data-loss pattern. The pattern only manifests on locks whose work-inside-lock runs in milliseconds rather than microseconds — long enough for a race window between two operations the lock acquisition does in sequence.
A rollback engine paired write results with their undo functions by looking up the original step via Array.find by name. If two steps shared a name, find returned the first one — so the first step undo ran twice (once with each step result) and the second step undo never ran. The fix is one of the simpler refactors: capture per-step state on the step record directly, instead of looking it up by name later.
An override-redemption auditor command displayed "0 total" with exit code 0 when the redemption file was a top-level JSON array — indistinguishable from a legitimate no-redemptions state. An auditor would conclude no overrides had been redeemed when the truth was the file was malformed and the redemption history was effectively gone. The fix is a typed shape check before the data crunching, with a typed error pointing at the file to inspect.
An email-classification tool extracted structured fields from a Claude call and stored them as a binding. If the LLM omitted a required field, the downstream storage layer supplied a silent default — turning an extraction failure into a successful-looking bind with bogus values. The fix is fail-loud at the extract boundary, not soft-default at the storage boundary.
A case-name search lowercased its input and stripped accent marks. A guard layer in the same codebase lowercased and stripped EVERY non-alphanumeric character. So the guard would correctly match "ODoe" against "O Brien" — but the search would not. An attorney searching "odoe" came back empty-handed and opened a duplicate case file for an applicant who was already in the system under a different spelling. Fix: align both normalizers.
An LLM-backed classifier takes a list of records and returns a list of classifications. The caller is supposed to match them back by id. If anything breaks the matching — missing input ids, dropped output records, a typo in the task name that produces empty results — the caller sees a partial answer and treats it as authoritative. The fix is to make every alignment step explicit and noisy.
A binding-storage tool computed a SHA-256 hash of the PDF and stored it on the binding to enable later tamper detection. The hash computation ran AFTER the finally block that cleaned up the temp directory. So in the common case where the PDF lived in a temp directory, the file was already deleted by the time the hash was attempted, the hash stayed null, and the downstream guard treated no-hash bindings as OK. The tamper guard was effectively disabled for the common case for an unknown number of releases.
A content-regex meant to detect a silently-failed session resume scanned the AI output for words like "scrambled" and "fragment". For a Workers Compensation tool that processes medical reports — where "bone fragment" and "DNA fragment" appear in roughly every other document — this fired constantly, silently clearing the attorney session mid-conversation. The fix is to delete the regex: the structural signal it was approximating was already being detected, just not acted on.
An XLSX-to-CSV converter that copies cells verbatim has a security hole: cells starting with =, +, -, @, tab, or carriage return are interpreted as formulas when the CSV is opened in Excel, LibreOffice, or Google Sheets. A malicious XLSX can embed a formula that fires on the recipient. The fix is a single apostrophe — but knowing to apply it is the part that takes a probe round to find.
A previous release added compression-and-rotation to an audit log. The compressed archives sat on disk indefinitely, exactly as designed. But the tool the attorney uses to walk recent history only read the current log file. Anything that had been rotated was invisible — even though it was still there, in a sibling file, perfectly recoverable. Six releases passed before anyone tried the reader on a post-rotation system.
A queue-coordination lock between two long-running processes had a structure that looked like a lock but provided no mutual exclusion. Two concurrent processes could both pass the existence check and both enter the critical section. A stress probe measured 98 percent data loss — out of 300 expected writes, only 6 survived. The fix is the proven O_EXCL pattern from the rest of this codebase, with one specific deviation that surfaced during the fix.
A setup wizard accepted "attorney" as a valid role (case-insensitive validation passed), but stored it verbatim. The downstream code that distinguishes attorneys from paralegals compared roles for exact equality, so the lowercase entry silently missed every check. The fix is a normalizer that maps the user-typed value to the canonical capitalization before persisting.
A background prefetcher that returns existing cache files without validating them, plus a directory created with default umask. Together: garbage files get treated as valid PDFs and propagate through the downstream extractor, while the privileged medical records in the cache directory are readable by every other user on the system. Two small fixes — a 5-byte magic check and an 0o700 mode — close both.
A task-walker had a regex meant to hide auto-generated cleanup tasks from the attorney queue. The regex started with `^VERIFY` to catch a specific older auto-task prefix. It also matched every legitimate attorney task starting with the word "Verify" — "Verify date of injury with client", "Verify subpoena was served", "verify settlement amount." Those tasks existed in the system, but the walker never showed them. The attorney could miss deadlines on tasks the walker was supposed to surface.
A PDF inspection tool that returns "0 pages, 0.0 MB" with exit code 0 when given an empty file, a non-PDF, or a file containing only the PDF header line has a subtle but critical failure mode: callers that use the tool to validate "is this a usable PDF?" cannot distinguish the broken-input case from a (theoretically valid) zero-page PDF. The fix is to surface the parse failure as a distinct return value, and to exit non-zero so chained shell commands see the failure.
A probe of the audit-log rotation path under 10 concurrent writers found 40 to 126 entries lost per 500 written. Two bugs combined to produce the loss: no synchronization between append and rotation truncate, and a rename-loop race in the numbered-archive scheme. The fix is a file lock around the read-rotate-write sequence, plus a switch from numbered to timestamp-suffixed archives so no rename loop is needed.
An HMAC-signed override-token system that does String(params[k]) when canonicalizing parameters has a subtle hole. Any object-valued parameter coerces to the literal string "[object Object]", which means two completely different nested objects produce the same MAC. A token issued for one nested-param operation could be replayed against any other operation with the same top-level key shape. Real callers were not currently affected because they passed scalar params only — but the contract was unsafe for any future structured-param override.
A function that builds its stored object field-by-field from the caller-provided entry has an implicit schema: only the fields the function explicitly reads are persisted. Any other field passed by the caller is silently discarded. For a binding-storage API whose job is preventing case-file misfiles, "field looked stored but was not" is the exact failure mode the storage layer exists to prevent. Plus a sibling bug where whitespace in the lookup key created phantom records that could not be reached by the obvious form.
Staff records in this tool come from an upstream API where users can edit their own display name. Those names land verbatim in the assembled system prompt. A staff name with embedded newlines plus an injected instruction would render as new lines of the prompt — the model has no way to distinguish them from legitimate content. This release sanitizes the fields before injection: strip control characters, collapse whitespace, defang template-placeholder syntax, hard-cap length.
appendFileSync returns when bytes are in the kernel page cache, not when they are on disk. For an audit log that has to survive power loss, that is a hole. The fix is openSync plus writeSync plus fsyncSync plus closeSync. Separately, no SIGTERM handler meant a kill between fetch and audit-write left only the pre-flight intent in the log — no way to distinguish killed from pending. This release closes both gaps in the same surface.
await res.json() returns whatever the body parses to — including null, top-level arrays, and primitives. If the next line reads a property off the result, valid-but-unexpected JSON crashes the process with a TypeError. For a write path that audits rejected attempts, the crash happens before the audit entry is written. That is the failure mode the audit log exists to prevent.
The clock-skew bug — wall-time subtraction compared to a positive bound, with no guard against negative ages — has now been fixed in six places in this codebase. The first three (lock files, cache TTL, session file) each came up through a deliberate probe. The fourth, fifth, and sixth all shipped in one release this week, after grepping the codebase for the pattern. At this point the shape is no longer surprising. The interesting question is whether shipping the shared-helper version would actually save anything, given how mechanical each individual fix is.
The probe series shifted to the session/resume layer this week. The two issues that surfaced were both shapes we'd seen before. One was clock skew on a wall-time comparison — the third instance of that family this project has shipped a fix for. The other was loose substring matching producing false positives on error detection — the fifth or sixth instance of that family. Both fixes are small and the pattern they belong to is now familiar enough to spot on sight. This post is about the two specific bugs and the value of recognizing recurring shapes.
The probe series shifted to the agent loop this week. The first thing surfaced was the script-mode entry point — `aaicase -p "question"` — which is how the tool gets invoked from automation, cron jobs, or wrapper scripts. Two issues. One was wasteful: a whitespace-only argument made it past the empty-check and got sent to the model as a real query. The other was confusing: a model that returned empty content produced no output and exited cleanly, leaving the script caller with no signal whether the query worked or silently failed. Both fixed.
After the sequence-runner round, the next probe target was the prompt-assembly layer. Specifically: what happens if knowledge.json — the file that captures who works at the firm — has staff entries with missing fields? The result was the literal string "undefined" leaking into the system prompt three different ways, where the model would then see it and treat it as text content. Three small fixes and one regression detector later, the prompt is now defended against the kind of corruption that hand-edited config files produce.
The keyboard-UX probe series caught six interactive bugs over the past two weeks. The next surface to look at was the sequence runner — the small library that executes multi-step API writes and rolls back on failure. The math of the rollback runner has been verified for months. What hadn't been probed was the spec-input layer: what happens if the spec is mostly right but quietly wrong in a way that would produce successful-looking output? Three weak spots surfaced. Each one would have let a sloppy or hand-edited spec through with no signal to the attorney that something was off.
The previous post in this series ended with a promise: extract a shared helper so future readline-config tweaks land in one place instead of seven. This release does that. The factory is forty lines. The benefit is that the next time we tighten a keyboard default, we won't have to grep for createInterface calls and edit them individually. This post is about what the helper does, the cases where helpers like this are worth extracting and where they aren't, and the small habit of doing the extraction while the lesson is fresh.
Two earlier fixes in the keyboard-UX series — the Esc-eats-next-character bug from 4.5.76 and the piped-output-pollution bug from 4.5.80 — landed only on the main REPL's readline configuration. This week's probe surfaced that the codebase has four other readline.createInterface calls, every one of which had been carrying both of the same bugs. The worst was the task walker, where the bug was visible to any attorney who tried to reassign a task and changed their mind mid-typing. This post is about the four sibling sites and the broader pattern of fixes that should have been one shared helper from the start.
The keyboard probe series caught another bug — this one not about keys an attorney presses, but about what comes out when an attorney runs the tool non-interactively. Piping a script into aaicase and capturing the output to a log file produced a file with raw ANSI cursor-positioning escapes interleaved with the actual content. The cause was a hardcoded `terminal: true` flag that lied about the output type when stdout wasn't actually a terminal. This post is about the small fix and the larger pattern of "behavior that's right in the interactive case and wrong everywhere else."
The keyboard-UX probing surfaced two more issues this week. One was a feature that had been written but never wired up — Tab completion existed as a function but was never connected to the readline interface, so pressing Tab just inserted a literal tab character into the prompt for weeks. The second was a convention violation — Ctrl-C used to immediately kill the session, discarding whatever the attorney had typed, while every other interactive REPL clears the line on the first Ctrl-C and only exits on the second. This post is about both fixes and the broader pattern of "code that exists but doesn't take effect."
Every newline in a multi-line paste was being treated as Enter — submitting partial content as separate queries while later lines piled up. An attorney pasting case context from a document would see three or four queries fire in rapid succession from what they intended to be one paste. The fix is a terminal feature that's existed for decades: bracketed paste mode. This post is about why the bug had been invisible for so long, what BPM actually does, and what it took to wire it into the existing readline-based REPL without breaking everything else.
For weeks the system's safety mechanisms, audit log, and override tokens have been the subject of careful verification. The interactive REPL — the part of the system the attorney actually types into — had been used but never deliberately stress-tested. A probe this week through the expect utility surfaced a real keyboard bug: pressing Esc to clear the prompt, then typing the next command rapidly, would eat the first character of that next command. The cause was readline's escape-sequence timeout interacting badly with the application's own Esc handler. This post is about how the bug was found, why it had been silently affecting every fast typist, and what the fix says about testing keyboard input directly.
The file-upload path in this tool has had an allowlist for as long as I can remember: file=@/path arguments must point at something under /tmp/ or ~/.aaicase/, the directories the firm controls. The check was implemented with path.resolve and string-prefix comparison. This week a probe noticed that path.resolve does not follow symlinks. A symlink in /tmp/ pointing at /etc/passwd passed the prefix check, and the readFileSync that came next happily followed the symlink and uploaded the target. Verified live before the fix went in: /etc/passwd left the local machine. This post is about the bug, the fix, and what threat model it actually addresses.
A small probe of the file-upload path surfaced a small problem. The error message for a file that exists but is unreadable said "API request failed" — implying the network call to Merus had gone out and come back rejected. The network call never happened. The file couldn't be read off the local disk. The error was being reported at the wrong layer. This post is about why that matters: a misleading error doesn't just confuse the attorney, it sends them to investigate the wrong system.
A probe with a pathologically long URL (four thousand characters of nested path segments) revealed something small and worth fixing. The system correctly handled the URL — sent it, got back a 404 from Merus, returned an error. What it did not do was format the error in a way a human could read. Four thousand characters of unbroken path got dumped to stderr. This post is about the small fix and the broader habit it points at: defensive code should be defensive about its output too, not just its input.
The audit log in this tool records every write attempt the firm makes against the production API: accepted writes, rejected writes, guard refusals, override token mints and redemptions. Until this release, every write was committed to the operating system's page cache but not necessarily to disk before the next operation began. A power loss in the wrong window would lose audit entries that had been "written" from the program's perspective. For a legal-grade record, that's not a trade-off — it's a defect. This post is about adding an fsync to the audit-log append, why the cost is small, and why the property matters.
The override system in this tool lets attorneys deliberately bypass a code-level guard for one specific operation. The token is HMAC-signed against a per-install secret, bound to the exact kind and parameters of the bypass, and single-use. The signing math has been stable for weeks. What we found this week is that the surface around the math — the mint UX, the verify UX, the operational tools — had several gaps where a token could be minted that would never work, or where the verifier saw different params than the minter had signed. Four small fixes shipped, and the override system is now meaningfully more usable.
For weeks the test harness for the skill regression suite had reported five failures, every run. Each one looked like a different harness artifact — quote handling, extraction regex misparsing the embedded JavaScript. We marked them harness-side false positives and moved on. This week we finally ran the skills end-to-end with no harness in the way — extracted the bash block, ran it through bash directly. Four of the five had been harness artifacts. One of them was a real bug in the skill that would crash on every invocation. This post is about that bug and the lesson it carries: a test harness that produces noise indistinguishable from signal is worse than no test at all.
For a long time the system's audit log captured what happened: every accepted write, every API-rejected write, the full trail of what the firm actually did. What it did not capture was what the firm tried. A code-level guard would refuse a write — task-delete, applicant-mismatch, filename-format — and the refusal would print to the attorney's terminal but leave no record in the audit log. This post is about closing that gap, about a temporal-dead-zone bug that nearly took the fix down with it, and about why "I tried to do X" is part of the legal record even when the answer was no.
Three files on disk hold the security-critical state for a workers' compensation tool: the upload bindings, the message bindings, the redemption record for single-use override tokens. Each one is read on every write. Each one had a fail-soft return-empty fallback on parse failure. Two of the three were correctly fail-soft — refusing writes is the right move when verification state is unreadable. The third — the redemption file — was a silent ticking time bomb: a single filesystem corruption event could re-enable replay of every previously-used override token. This post is about the difference, and about when "return empty and keep going" stops being safe.
Earlier in this project we shipped a fix for a class of error where the underlying API returned HTTP 200 with an errors array in the body. The audit log was treating those as accepted writes and silently dropping them from the trail. We fixed the audit log. We assumed the lesson was learned. Then over the next week we shipped four more fixes for the exact same pattern at four other call sites: aaicase --check, the dashboard status line, the setup wizard token-test, merus-events, merus-search. This post is about how a pattern repeats when you fix only the instance instead of grepping for the shape, and what the grep-for-the-shape pass surfaced.
After the day's twenty-fourth release we ran an extended skills regression — every read-only skill against a real case, the walker boot-to-quit, the sequence runner with live writes against the production API. Two bugs surfaced. One was a skill calling endpoints that don't exist. The other was deeper: the audit log was silently dropping every write the API rejected. For a legal-grade tool, an invisible write attempt is worse than a failed one. This post walks through both fixes, why "the API said no" still belongs in the audit trail, and how the production soft-delete pattern was returning success through the error code path the whole time.
After thirteen releases we stopped shipping new features and audited everything we had shipped that day. The audit produced a fresh list of fifteen weaknesses, four of which were significant — and one of which surfaced only because the first round of guards now existed. This post is about those four fixes: single-use override tokens, pre-rollback state verification, the PDF hash check finally reaching production, and a static analyzer that prevents a documented rule from silently regressing.
Two days ago we built a rollback runner — a small module that executes multi-step API writes and reverses earlier writes if a later one fails. We wrote about it as a closed safety property. It wasn't. The runner existed in the codebase but the production filing flows still issued their writes through the old path. The runner was a library nobody called. This post is about closing that gap, what verification turned up while we were closing it, and the discipline of treating "we built it" and "it ships in the production path" as two different commits.
Three small improvements shipped across one day — none of them headlines, all of them addressing places where the system accepted attorney input but failed to fully communicate what it did with that input. A parser that quietly dropped half the instruction. A reassign picker that couldn't distinguish two staff with the same first name. A multi-step write that left half-completed work behind on partial failure. None of these were bugs in the safety architecture; they were gaps in what the system told the attorney about its own behavior. This post is about why those gaps matter and what closing them looks like.
After we shipped the first layer of code-level guards, we ran a structured audit on what we had built. The audit produced a list of weaknesses that the guards themselves had not addressed — places where the safety property we claimed only held under certain conditions. This post walks through four improvements we made over a single day in response to that audit, what each one closes, and why a second pass is non-optional for any safety-critical system.
A small but meaningful upgrade to the task walker. When you walk a queue filtered to a specific staff member, every write to MerusCase is attributed in MerusCase to the user the API token belongs to — not to the filtered staff member. That distinction was invisible before. Now it is shown in the header, repeated on every task card, and a warning fires when the filter and the writer are different. This post explains why we made the change, what it looks like, and what it does and does not solve.
The day we shipped the code-driven task walker, an attorney tried it on her caseload. The first task came up. The walker offered four options: complete, update, skip, quit. She wanted to do something different. The second task came up. She wanted to look at the documents on the case before deciding what to do with the task. The third task came up. It was assigned to a paralegal who had been on leave for two months — she wanted to reassign it. None of those operations existed. This post is about what an attorney actually does when going through a task list, and how we extended the walker without losing the safety properties that made us build it.
The day after we shipped the no-delete rule, the attorney tried the task walker again. The model offered "Delete both" anyway. It also batched two tasks into one card and interpreted a typo as a skip. The rules had not failed — they had been ignored. This post is about the architectural answer: when a flow needs guarantees, the model cannot be the thing running the loop. A CLI runs the loop. The model is consulted only for explanations. We shipped that change in 4.5.13. This is what it looks like, why it works, and what other flows are next.
An attorney asked our system to walk through her open tasks. The first one was eight years old, on a stipulated case from 2017, with three words for a description: "$5710 DIFFERENCE." The system offered three choices: complete, look up the case, or delete. Two of those three options were wrong. This post is about why delete is almost never the right answer for a law firm, what the system should have shown instead, and how we rebuilt the task-walking flow so it can never propose deletion again.
A workers' comp firm discovered two uploads had been filed on the wrong cases. The PDFs were swapped — Roe's document on Doe's case, and vice versa. The activity descriptions were correct. The filenames were correct. The PDFs themselves were just attached to the wrong activities. This post walks through how the bug happened, why prompt-level rules cannot prevent it, and the architecture we shipped to make it structurally impossible.
Type "morning brief" and see every danger case, deadline, and overdue task across 150+ cases. What used to take 45 minutes now takes one command.
AI reads each PDF, identifies the case, names it with the doctor and date, files it, and creates deadlines. You approve each action.
Under 8 CCR 31.5(a), you have 30 days to object to a QME report. Most firms discover the report too late.
AI reads every document on a case, cross-references body parts against the Application, and finds money on the table.
PD, TD, SJDB, penalties, mileage, future medical, interest — with net-to-client math after fees and liens.
The #1 malpractice claim in Workers' Comp is a missed statute of limitations. How do you track SOL across 150 cases?
Client info, parties with phones, open tasks, flags for what's missing, interpreter status — one command.
SOL countdown, QME objection, IMR appeal, Petition for Reconsideration, DOR response — all with statute citations.
LC 4650(d) automatic 10% self-increase. LC 5814 up to 25%. Most firms don't track payment timing across 150 cases.
Which records are outstanding? Which are overdue? Which cases have hearings approaching with records still pending?
Guided 7-step intake: conflict check, SOL calendared, 11 tasks created with attorney/paralegal assignments.
NOR, lien letters, TD demands, client updates — with correct names, dates, claim numbers, and statute citations.
Every open case scored and ranked. The 10 worst cases first. Run it weekly.
Pull the full billing ledger: costs advanced, time billed, payments received, outstanding balance — feeds into settlement math.
On Monday, it shows everything since Friday. New uploads, activities, tasks across all cases.
How many unprocessed uploads are in the queue right now? No AI needed — hits MerusCase directly.
Counts, flags, client contact status, value snapshot — the quick-but-thorough case review.
Compare two cases for the same applicant — body part overlap, credit analysis, settlement implications.
Download any upload from MerusCase and get an AI summary: document type, key findings, action items.
Applicant, defense attorney, carrier, adjuster, QME, judge, interpreter — with contact information.
Search for any contact firm-wide by name. Find Dr. Smith, find an adjuster by carrier name, find defense counsel.
Access the reports MerusCase provides through the API — stale cases, overdue tasks, DOI lists.
See real tasks on a case — auto-generated "Review filed orphan upload" noise is automatically hidden.
Save a morning brief, health check, or settlement analysis to a file for sharing or documentation.
Set up your morning brief to run automatically every day at 7 AM and email you the results.
Instant firm-wide snapshot — no AI, under 5 seconds. How many cases, tasks, events right now.
The QME is treating the right shoulder. The Application doesn't list it. That's $30K-$50K in PD value at risk.