A crash is a gift.
It stops you cold, it names a file and a line, and it makes the problem your problem right now, while you still remember what you were doing. Nobody ships a crash to production and goes six weeks without noticing.
The bugs that hang around in a small firm’s software are the ones that don’t make a sound. The button that looks like it worked and did nothing. The document that saves just fine to the wrong place. The calendar sync that quit in March and has been handing back an empty list ever since, cheerfully, right on schedule.
Those aren’t edge cases. Building and running the tooling for this practice, I’ve found silent failure is the dominant failure mode, and every single instance of it had one thing in common: the software had a perfectly good explanation sitting right there and threw it away.
This piece is about how that happens and what to do about it. It’s a companion to How Do You Know It Works?, which is about measuring whether an AI workflow produces good output. This one is about the layer underneath: whether the machinery ran at all.
(Every example below is an invented composite. No client information, matter numbers, or real data appear anywhere in this article.)
Four ways it hides
1. The runtime that answers for you.
You build an internal tool as a web app and test it in your browser, where everything works. Then you wrap it in a native window so it gets a Dock icon. Inside that wrapper, the built-in confirmation and prompt dialogs don’t exist unless the host application supplies them, so confirm() quietly returns false and prompt() quietly returns nothing.
So every delete confirmation quietly says no. Every rename quietly cancels. No error, nothing in the log, and the feature just sits there dead. The user reports it as “rename isn’t working,” which is exactly right and tells you nothing.
This isn’t about any one framework. You tested in a runtime you don’t ship.
2. The helpful default.
A function that starts a process takes a folder to run in and, sensibly enough, falls back to a default location if it can’t read the folder it got. Somewhere upstream, a caller passes a path with an unexpanded ~ in it. The fallback fires. The process runs in the wrong folder.
Nothing errored. Everything “worked.” The output is sitting someplace nobody will ever look.
A fallback is a decision the software makes for you. A silent fallback is a decision it makes and then hides from you.
3. The wrapper that eats the explanation.
A small helper program does one job: read the calendar, fetch a record, convert a file. When it fails, it writes a clear, plain-English explanation as structured output and exits with a non-zero code, exactly like it should.
The calling code runs it, sees the non-zero exit, and reports the exception it caught:
Command failed: /Users/…/bin/calendar-dump 21 1
The real message — this application does not have permission to read your calendars — was sitting in the helper’s output the whole time, unread, because the exception object and the output stream live in different places and somebody only checked one of them.
Now the user is told a command failed. They can’t do a thing with that. The system knew exactly what was wrong and couldn’t get it across four inches of code.
4. The permission answered once.
Somebody clicks “Don’t Allow” on a system permission prompt — by accident, in a hurry, because it popped up over something else. On macOS that answer sticks. The operating system records the denial and quits asking. The API doesn’t throw an error. It returns no, forever.
The feature doesn’t tell you it lost a permission. It just returns empty results from that day on. If your interface shows an empty result the same way it shows “there is nothing scheduled,” you’ve built a system that says your calendar is clear when it means I cannot see your calendar.
For a litigator, those two sentences aren’t even in the same zip code.
This is a lawyer’s problem, not a tech one
Every example above has a twin in law practice, and the consequences are real:
- A document generated into the wrong matter folder is a confidentiality problem waiting for the next person who opens that folder.
- A calendar integration quietly returning nothing is a docketing system that reports no deadlines, and you can’t tell that apart from having none.
- A billing capture routine that quietly stops recording is unbilled work you’ll never reconstruct.
- A rule-verification step that fails open — returning “no problems found” when it couldn’t run — is worse than no verification at all, because you relied on it.
What they share: the broken state and the harmless state look identical to the user. Empty is empty. Silence reads as “fine.”
That’s the real defect. The bug itself is ordinary. The defect is that the system had no way to say I could not do this, so it said the same thing it says when there’s nothing to do.
Six rules I build to now
1. Test in the runtime you ship.
If it runs in a wrapper, an app bundle, a scheduled job, or a container, that’s where you test it. Three separate bugs in our tooling showed up only in the packaged application and never once in the browser used to build it. What’s convenient while you’re building isn’t a test environment.
2. Every fallback is loud.
If code swaps in a default for something it was handed, it has to say so — in the response, in the interface, in the log. “I could not use what you gave me, so I used this instead” is a sentence the user has to be able to read. Silent swaps are how work ends up in the wrong place.
3. Read the whole failure.
When you shell out to a helper, grab its output and parse it before you fall back to the exception text. A program that reports failures as data is doing you a favor. Don’t throw the favor away. This is a two-line fix that turns an error you can’t act on into one you can.
4. Tell “no” apart from “broken.”
An empty result and a failed request should never look the same. A calendar with nothing on it says nothing scheduled. A calendar that couldn’t be read says I cannot read your calendar, and gives you a way to fix it. Same for search results, matter lookups, document lists. Everywhere.
And this one has teeth: when the answer is “you denied permission,” offer the way back. If one denial kills a feature for good and the app gives you no in-app way to recover, that’s a bug in your product. Your user didn’t choose that.
5. Prove the wiring, then tear out the proof.
Wherever something can fail silently — a bridge between two runtimes, a scheduled job, an integration handshake — build a temporary self-test that runs it end to end and reports what it found. Confirm the round trip comes back the way you expect. Confirm an error shows up as an error. Then rip out the scaffolding.
Do it before you ship. Otherwise you’ve got a piece of machinery you’re just assuming works, in a category where “does nothing” is what both success and failure look like.
6. Answer the audit question.
For every automated thing in the office, you should be able to answer this: how would I know if this stopped working?
If your answer is “I’d notice,” you don’t have a system. You have a hope. Noticing takes a difference, and a job that fails silently produces no difference until the day the missing output matters.
Good answers sound like this: it writes a timestamped record every run and the morning report shows the last one; a failed run posts a message somewhere a person actually reads; the count of items processed shows up where I’ll spot an unexpected zero.
The part I don’t love
All of this got worse when I started generating more of this code with AI.
It’s not that the generated code is bad. It’s usually reasonable, and it’s often more defensive than what I’d have written, loaded with graceful fallbacks and swallowed exceptions and sensible defaults. That’s the problem. Graceful degradation and silent failure are the same code. The only difference is whether the degradation gets announced.
Ask a model to make something robust and you’ll reliably get code that doesn’t crash. Not crashing isn’t the goal. The goal is that when the thing can’t do its job, a human finds out.
So now it’s an explicit instruction when I ask for this code, and an explicit thing I hunt for when I review it: find every place this silently substitutes, defaults, or swallows, and make it say so. It’s a short review, it catches real defects, and it’s the best ten minutes I spend on any of this.
A tool that fails loudly costs you an afternoon. A tool that fails quietly costs you the thing it was supposed to be watching.
Sources
- The two macOS behaviors described above were verified by direct testing on macOS 15, not taken from documentation: (1) after calendar access is denied, an EventKit access request returns denied without re-prompting, and the recorded decision must be cleared before the system will ask again; (2) in an embedded web view, JavaScript
confirm()andprompt()return false and null respectively unless the host application implements the corresponding UI delegate methods. - Both findings are reproducible and were confirmed against the behavior of a working build before being written up here.
This article describes general engineering practice for internal law firm tooling. All examples are invented composites. It contains no client information and is not legal advice.