The bug that argued
for its own dismissal.
We shipped an app that could not launch on most Apple Silicon Macs, for four weeks and eight releases. Because a crashing install never reports anything, our own telemetry said nobody was affected.
A user sent us a crash report. Not a description of a bug — an actual crash report, the file macOS writes when a process dies before it has done anything at all. It said the app had failed to load because a system framework was missing.
It was correct. That version of Ampersand could not start on any Apple Silicon Mac running a macOS older than 26. Neither could the version before it, nor the six releases before that. We had shipped a completely unlaunchable app to a large share of our users for about four weeks, and every instrument we owned reported that everything was fine.
The instruments are the interesting part, so they come first. Here is what the operating system was telling people.
- releases shipped unlaunchable
- 8releases shipped unlaunchablev0.2.22 through v0.3.1
- before anyone told us
- 4 weeksbefore anyone told usfrom 20 July 2026
- crash report that found it
- 1crash report that found itsent by a user, unprompted
Termination Reason: Namespace DYLD, Code 1 Library missing
Library not loaded: /System/Library/Frameworks/FoundationModels.framework/...
Referenced from: /Applications/Ampersand.app/Contents/Frameworks/<bridge>.dylib
Reason: (built for macOS 26.0 which is newer than running OS)
Exception Type: EXC_CRASH (SIGABRT)
OS Version: macOS 15.7.1
Hardware Model: Mac14,2
main, so this is upstream of our logging, our panic hook, our crash marker and our diagnostics bundle.Ampersand can route AI features to Apple Intelligence, which means linking Apple’s on-device model framework — which exists only on macOS 26. Every entry point into it was already wrapped in an @available(macOS 26.0, *) guard, so on an older system the feature reports that it needs a newer macOS. That part still works. It never got the chance to run.
A crash before main is a crash your app cannot report
The dynamic linker resolves a binary’s load commands before your program starts. Not early in startup — before it. By the time any line we wrote could run, the process was gone.
Ampersand has a fairly thorough support apparatus, built so that a failure in the field arrives with evidence attached: structured local logging from the first line of the application entry point, a panic hook that captures a backtrace and leaves a crash marker for the next launch to find, a crash-loop detector offering a safe mode, an error boundary that replaces a blank window with a redacted diagnostics bundle you can export.
All of it sits downstream of process start, so all of it was structurally incapable of noticing this. The only thing that recorded the failure was the operating system, in a file a user had to find and send us. One did, within a day of the release — for which we remain grateful, because nothing else was ever going to.
The bug deleted the evidence that it existed
This is the part worth carrying to another codebase.
Ampersand sends one anonymous ping per day: an opaque install identifier, app version, OS version, architecture, a document count, and whether the licence is free or paid. No content, no filenames, no paths. It is not behavioural analytics; it exists so an app with no accounts has some idea how many people use it, and you can switch it off in Settings. The privacy page lists the exact payload.
That ping shipped in the same release that started shipping the hard link. The population that could have told us about this was, by construction, the population that never got far enough to send anything. Not one ping has ever arrived from an Apple Silicon Mac below macOS 26.
-- "which macOS versions are our users on?"
-- every row that could have disagreed died before it could answer.
os_version installs
---------- --------
26.x ~100%
15.x (no rows)
14.x (no rows)
-- reasonable conclusion: everyone has upgraded.
-- actual finding: everyone who had not, crashed.
Read that table cold and you conclude our users upgrade quickly, the older-macOS long tail is negligible, and a compatibility bug affecting it is a low-priority curiosity. Every one of those conclusions is manufactured by the defect. The bug supplied its own argument for being ignored, out of real data, correctly collected.
The general rule
The machine that built it could never see it
Our release runner is the newest macOS image available, because that is what you want for an up-to-date toolchain. On that machine the framework exists, so the app launches perfectly for whoever built it.
And every test tier runs on that same too-new host: the unit suites, the Rust tests, the browser end-to-end suite, the security-policy suite — and the real-binary smoke test, which launches the actual signed bundle, types into it and asserts a file appeared on disk. That last one exists to catch “works in development, broken when packaged”, and it could not catch this either — it was launching the app on an operating system where the app launches.
Same shape as the security-policy bug we wrote about separately: invisible everywhere we could conveniently run it, plainly visible in the artifact we ship.
The cause was one flag, and not the one you would guess
Our build compiled the Swift bridge with -target arm64-apple-macosx26.0 — the SDK’s own target — and a plain -framework FoundationModels. The obvious suspect is the second flag. It is the first one.
At a macOS 26 deployment target the compiler treats every symbol in that framework as unconditionally present, because it is: you told it you require macOS 26. The symbols become strong imports, and the linker emits a hard LC_LOAD_DYLIB — a load command meaning this library must exist or the process must not start. It behaved exactly as instructed.
Compile the same source at the app’s actual minimum version and the @available guards that were already in the code start doing work. The compiler now knows the framework may be absent, so the symbols become weak imports — and the linker emits LC_LOAD_WEAK_DYLIB on its own, with no change to the -framework flag at all. We verified this by building both ways and reading the load commands.
# built at the SDK's target -- the shipped bug
# swiftc -target arm64-apple-macosx26.0 -framework FoundationModels
$ nm -m bridge.dylib | grep FoundationModels
(undefined) external _$s17FoundationModels... # strong import
$ otool -l bridge.dylib
cmd LC_LOAD_DYLIB
name /System/Library/Frameworks/FoundationModels.framework/...
# built at the app's own floor -- same source, same -framework flag
# swiftc -target arm64-apple-macosx14.0 -framework FoundationModels
$ nm -m bridge.dylib | grep FoundationModels
(undefined) weak external _$s17FoundationModels...
$ otool -l bridge.dylib
cmd LC_LOAD_WEAK_DYLIB
name /System/Library/Frameworks/FoundationModels.framework/...
-framework flag is identical in both builds. Only the deployment target changed, and the linker reached the opposite conclusion about whether the library has to be present — which is why the fix is the target, not the flag.A weak load command lets the dynamic linker start the process without the library, leaving the symbols null; the @available checks then take the other branch. That is what we always intended, and what the app does now: below macOS 26 the AI settings read “Apple Intelligence requires macOS 26 or newer”, and everything else works.
So the fix is to compile at our own floor — read from the same place the bundle advertises it — not at whatever the installed SDK defaults to. We also pass -weak_framework explicitly, as defence in depth for a future call site outside an availability guard. But the deployment target is the half that was wrong, and must not be “simplified” back.
Worth recording: the main executable never referenced the framework at all. The dependency was entirely transitive through one bundled dylib, which is part of why nothing in the app’s own configuration looked wrong.
The guard reads the binary
No test in any existing suite could have caught this: they all run somewhere the bug does not exist. The only place the defect was visible before a user’s crash report is the shipped Mach-O’s load commands. So that is what the guards read.
The first runs during the build: after compiling the bridge it runs otool -l over the dylib it just produced, asserts the framework is weakly linked and the deployment target matches the app’s floor, and panics the build if either is wrong. It is the primary guard because it runs on the release machine every time, which no test suite here does.
The second walks a fully built application bundle — the main executable and every dylib beside it — asserting that no Mach-O’s minimum OS version exceeds the minimum system version the bundle declares, and that nothing on a list of newer-than-our-floor frameworks is strongly linked. It runs after the release step that rewrites load commands, so it inspects the exact bytes that get signed.
The minimum-version half is the general one. It knows nothing about Apple Intelligence; it catches any future component accidentally built at the SDK’s target, which is the root cause of the whole class. A new framework above our floor costs one line on a list.
One sharp edge, while we were in there
What we took from it
- A crash before your first line of code disables your entire support apparatus. Logging, panic hooks, crash markers, error boundaries and diagnostics bundles all sit downstream of process start. Budget for the only report being the operating system’s.
- Metrics collected by a program are conditional on that program running. A launch failure removes the affected machines from every aggregate you have — which reads identically to those machines not existing.
- Compile against your floor, not your SDK. Availability guards do nothing if the deployment target tells the compiler the APIs are unconditionally present. Linking behaviour follows from the target, not from the flag naming the framework.
- Assert on the artifact. When a defect is only visible in the shipped binary, the check has to read the shipped binary — and it should fail the build rather than a test suite, because the build is the one thing that definitely runs on the release machine.
Four weeks, eight releases, and a whole category of user for whom the app simply did not open. One person found a crash report and sent it to us; that is the only reason we know. The binary tells us now.
The technical specifications and security overview cover the rest of how the app is built, and a window that could not paint is a companion post on a different launch-path failure.
Built carefully.
Fixed in public.
Ampersand is a native markdown editor that keeps your notes as plain files on your own computer. Try it, and hold us to this standard.
Free to start · Mac, Windows & Linux · No account required