The Comments That Kept Their Own Bugs Alive
Nine releases in six days, v0.5.1 through v0.5.9, and almost none of the defects in them were reported by anyone. One comment said the platform would not let us hash any harder, so password storage stayed six times weaker than the standard asks for. Another said Xcode would rewrite the entitlement on the way to the App Store, which is why no iPhone could receive a notification. A # in a model name got Fable 5 served and Gemini 2.5 Flash billed. A trash route answered nothing for 142 days.

On this page
Between July 27 and August 1 we shipped nine releases, v0.5.1 through v0.5.9. Read the changelog and it looks like a normal week: frosted glass on the desktop window, a Work tab for Foxl Code, a password option, dark mode with actual depth. Read the commits and something else shows up. Almost none of these problems were reported by anyone. They came from running a query, a tail, or a contrast calculation against something we had already called finished, and getting an answer we did not expect.
A bug someone reports is a bug that had a symptom. Most of what follows had none, or had one that pointed elsewhere: zero registered phones looks exactly like a feature nobody wanted. And two of them were held in place by comments we had written ourselves, in the file the fix belonged in.
The comment that forbade its own fix
v0.5.5 added an optional password, which meant writing a password hash. The Cloudflare Workers runtime has no Argon2 and no scrypt, so the choice is PBKDF2 through WebCrypto, and OWASP asks for 600,000 iterations of PBKDF2-SHA256. Workers refuses that:
NotSupportedError: Pbkdf2 failed: iteration counts above
100000 are not supported (requested 600000)So the code shipped at 100,000, with a comment explaining that the platform capped it there, that no stronger primitive was available, and - this is the part that matters - do not fix this by raising the constant. A future reader would hit that comment, agree, and move on. It reads like the conclusion of an investigation.
The cap is per call, not per password. Feeding one derivation's output back in as the next one's input chains them, and nothing limits the chain. Six chained calls reach 600,000 for a few tens of milliseconds of server CPU on a login - the exact figure moves with the runtime, which is worth saying because the code comment quotes one number as though it were a constant. The comment had defended a hash six times cheaper to crack offline than the standard asks for, and it had done so by asserting a limitation that was true of one API call and false of the problem.
There is a second layer to this one. The mechanism that would have let us raise the cost later - re-hashing an old password on its next successful sign-in - was dead code. The condition was params.iterations < KDF.iterations, and the hashing function always wrote exactly KDF.iterations, so the strict comparison could never be true. Checked against production: every row carried one identical parameter string. The upgrade path became reachable for the first time in the same commit that discovered we needed it.
The fix stores the work factor per row (pbkdf2-sha256$100000x6) and compares total work rather than the per-call number, so an old row keeps verifying with its own parameters and is upgraded transparently the next time you sign in. And the ceiling is now documented as a fact about one API call rather than as a verdict.
A scrambled email address is not scrambled
The same feature counted failed sign-in attempts, and it counted them against the address rather than the account on purpose: if only real accounts could lock, then eight wrong guesses would tell an attacker whether an address is registered, which defeats the identical error message the route exists to produce.
To avoid keeping a list of addresses, the attempts table stored a SHA-256 of each one. The comment said that made the table "not a mailing list". An email address has almost no entropy - a name from a short list against a domain from a shorter one - and SHA-256 is built to be fast, which is the opposite of what you want here. One thread does millions of candidate hashes a second, so recovering the addresses in that table is a matter of typing out the guesses. It was a mailing list, including addresses with no account behind them, which is data we had no reason to hold at all.
It is now an HMAC keyed with the environment's own secret, so there is nothing to brute-force against without the key, and a staging row cannot be correlated with a production one.
Both of those defects, and five more, came out of auditing the password work a few hours after it shipped rather than from anyone using it. The whole cycle - feature at 11:10, the lockout and account-oracle fixes at 11:46, the hashing and keying fixes at 12:44 - took 94 minutes on one morning. We mention the timestamps because the useful lesson is not "audit your work" in the abstract. It is that the audit found things the tests could not: the suite passes either way, since a hash at 100,000 iterations verifies perfectly against itself.
Eighty-three times the wrong price
Model names arrive from clients in many shapes, so the relay normalises one before doing anything with it - stripping version suffixes, region prefixes, and anything after a #. That normalised id is what the plan check uses and what gets sent upstream, which is correct.
Metering used the raw string the client sent. And the price lookup, when an exact row was missing, fell through to scanning the whole pricing table and returning the first row whose model id appeared anywhere inside the request's string.
Put those together and claude-fable-5#gemini-2.5-flash passes the entitlement gate as Fable 5, is served Fable 5 by Bedrock, and is billed as Gemini 2.5 Flash. From the committed pricing migrations: Fable 5 output is $50 per million tokens, Gemini 2.5 Flash output is $0.60 - a factor of 83 on the output side. The scan had no ORDER BY, so the row it happened to return was not even stable between calls.
Metering now uses the model the request actually resolved to, and the price lookup is exact-match only with no fallback scan. Worth being honest about the blast radius: the entitlement gate still applied, so this was an undercharge available to someone whose plan already included the expensive model, not a way to reach a model you had not paid for.
The check that ran and decided nothing
The desktop app runs a local server, and production builds protect it with a token. Requests from a browser were also passed through a CORS middleware, which is the part that looks like access control.
It was not. That middleware set an Access-Control-Allow-Origin header and called next(). There is no early return in it and no 403 - the request ran regardless of where it came from. CORS is enforced by the browser deciding whether your JavaScript may read a response, and it says nothing about what the server did.
The gap that opened was in the other header. Nothing checked Host, so a site that pointed its own domain at 127.0.0.1 was same-origin to the browser as far as its own page was concerned. Its JavaScript could fetch /, which is not an API path and so was exempt from the token check, and read the connection token that the server injects into that HTML for the app's own use. With the token, the whole local API was available.
Requests that do not arrive as localhost are now refused, by a middleware registered before the CORS one - and the regression test asserts the order of the two rather than the presence of either, because a Host check that runs after the response has been decided is decoration.
Five faults, stacked so each one hid the next
We wrote about push notifications in the v0.5.0 post: Apple hands over a device token once per install, seconds after launch, before anyone has signed in, and we discarded it in that case. That was true and it was one of five independent faults in the same feature. Each one was invisible while the one before it was broken.
Behind the discarded token, every TestFlight build had been signed for the notification sandbox, so iOS was never issuing a token to discard in the first place. The entitlement said development, and carried a comment claiming Xcode rewrites that to production when it archives for the App Store. It does not. Xcode selects a provisioning profile that matches your entitlement, so declaring development is exactly what pulls in a development profile. Another comment asserting its own correctness, keeping a bug alive.
Behind that, once iOS would issue a token, the native code that hands it from the app delegate to the notification plugin was absent - not wrong, absent. Behind that, the desktop app raised its notifications locally and never put them on the wire, so the relay had nothing to forward. And behind that, the phone had no screen to read a notification on: the bell existed only in the macOS titlebar and the Windows header.
Fixing any one alone would have changed nothing observable, which is why this took five releases. The part worth keeping is how each was found, because none of them was findable from inside the product:
- An empty device table in the production database - the only way to learn that a feature with working toggles had zero devices behind it.
codesign -d --entitlementson the shipped binary, which reportedaps-environment=developmenton a build App Store Connect had already marked valid. That validation inspects the app bundle; it has no opinion about whether push can function.- A live tail of the production relay while the app ran, showing it calling four other endpoints repeatedly and never once attempting to register a device. That last one is inference by exclusion: a client that never tries rules out the network, the token and the permission prompt in a single observation.
The guard we wrote afterwards reads the exported .ipa rather than the archive it came from, and that detail was learned the hard way - the first version read the archive and failed a perfectly good build, because the export step re-signs. Measured on one build: the archive was development, and the .ipa produced from that same archive was production.
A route that never worked, for 142 days
v0.5.6 added a Chats panel where a deleted conversation can be read and restored inside its seven-day window. The endpoints for that already existed. They had never once answered a request.
GET /api/conversations/:id was registered before GET /api/conversations/trash, so Express matched the literal word "trash" as an id and answered Conversation not found. Both routes arrived in the same commit in March - :id at line 2289 and trash at 2404 - and walking the 140 revisions that touched that file between then and the fix finds the correct ordering zero times. The feature was written, correct in isolation, and unreachable for 142 days.
The same release found something less comfortable next door. Deleting a chat removed the chat. Nine tables hold pieces of a conversation, only one declared a cascade, and one of the others stores the text of the instructions you gave a subagent, word for word. One query on the machine where this was investigated found 342 rows belonging to conversations that no longer existed, some deleted months earlier, still holding their prompts. That count is a snapshot rather than something you can re-run: the sweep the same release added has taken it to zero. Deletion now routes through a single function that both delete paths call - the two had kept separate hand-maintained lists and disagreed about what "deleted" meant.
Re-running that query while writing this post turned up a gap the fix does not close. Three tables in the purge list have no conversation_id column on the shipped schema, so those statements throw and get swallowed. Two tables that do carry one are in neither the purge nor the sweep, and both still have orphans right now: 4,559 rows in usage_records and 88 in schedule_executions. A structural test that checks a table name appears somewhere in a function will certify a statement that cannot run, which is how a purge covering twelve tables actually clears five.
The design pass that was mostly arithmetic
v0.5.1 through v0.5.3 were a design pass, and the useful thing about a design pass is how much of it turns out to be computable. Several problems that had survived months of people looking at the app every day fell out of running the numbers on the rendered UI.
The selected row in the sidebar measured 1.002:1 against its own background - two colours differing by less than a rounding error, so the selection was communicated entirely by font weight and the fill was doing nothing. The Foxl mark in dark mode measured 1.1 to 1.5:1 against a 3:1 floor for graphics, which is why all anyone could see of it was the small blue wedge. Seventy-five labels were drawing a muted colour at reduced opacity, landing between 1.96:1 and 3.23:1 where small text needs 4.5:1. De-emphasis by opacity is the mistake there; size and weight are what that job is for.
The accent gold is bound by the same arithmetic: at 4.02:1 on white it clears the 3:1 floor for an icon and misses the 4.5:1 floor for small text, so it may draw a symbol and may not draw a sentence. Checking that for this post, the figure in our own design notes turned out to belong to a slightly different gold than the one the app ships.
What the glass costs
On macOS 26 and later the window's outer chrome is a real NSGlassEffectView, so your desktop shows through it, with the inner content card left deliberately opaque because that is the surface you read on. The interesting constraint is a single number: the tint that keeps small sidebar labels legible over an arbitrary wallpaper leaves the glass owning about 28% of each pixel. That is why macOS dimming its own material when the window loses focus is imperceptible here, and why the app has to state the change itself - which it does by draining the hue out of the surface and leaving the alpha exactly where it is, because the alpha is what holds the contrast floor. An inactive macOS window goes flat, not dark, so that is what the app imitates.
The change that added the boot window nearly shipped a DMG that could not launch. electron-builder's file list is an allowlist, the two new files were not in it, and the packaged main process would have failed to require them - invisible in development, fatal only in the signed build. There is a gate for that class now, cross-checking every local require against the allowlist.
Foxl Code learns to wait for you
The Foxl Code work in v0.5.8 and v0.5.9 is the part of this stretch that is a feature rather than a correction, and it has the same shape underneath: the loop could act, but it could not tell you it was stuck.
A Work tab now lists a repository's open issues, open pull requests and what recently merged, each row joined with what Foxl knows - which agent is on it, whether the build passed, whether the reviewer approved. Solve puts an agent on an unclaimed issue in one press, re-reading the issue from GitHub at that moment rather than trusting the page.
Auto-merge is off by default and fails closed - a missing settings row or a failed database read is not consent to write to your default branch. When it is on it needs a green build and a reviewer approval. As booleans, both gates together still merged code nothing had graded. An agent pushes commit A, CI stamps green, the reviewer approves A, the merge is attempted, and GitHub refuses because a second CI app has not reported. The agent pushes commit B. B gets approved, and the stale green from A completes the pair. Pinning the merge call to the current head does not help, because the current head really is B. Each gate now records the commit it was measured against, and both must match the head that would merge. A gate is a statement about a commit, not a flag.
Two things it does not do. The risk fence around the self-directed loop - no major dependency bumps, no migrations, no auth or billing changes - is instruction to the model, not enforcement in the server. And the guard against two agents racing on one issue is scoped per person, so two members of a shared organisation install can still start rival work on the same issue.
The smallest piece is the one that changed the day to day. Foxl Code can now stop and ask you something, and you can answer from a locked phone without opening the app. Before this, anything it needed to ask - two repos match that name, this step will force-push, this change is bigger than one pull request - came out as a sentence in a reply, which is seen by an open browser tab and by nothing else. Phone locked and laptop shut, the run simply stopped and nobody was told. Questions are durable items now, and one that goes unanswered for a week lapses rather than waiting forever - lapsing is never read as approval.
What we changed about how we check
Two of these were comments. One said the platform would not let us hash any harder; the other said Xcode would rewrite the entitlement on the way into the App Store. Both were written by someone who had just looked it up, which is exactly why nobody looked again. Tests were no help either - a hash at 100,000 iterations verifies perfectly against itself, and a route that answers "not found" is answering.
What did help was cheap and unglamorous, and mostly lived outside the code: a query against the production database, a wrangler tail while the app ran, codesign -d --entitlements on the artifact we had actually shipped, a contrast ratio computed on a rendered pixel. Three of those became gates - the entitlement check before an upload, the packaged-file cross-check, the route-order assertion. The rest are just numbers in commit messages, which is the point: writing this post meant re-running them, and that is how we found the deletion sweep clears five tables rather than twelve, and that our own design notes quote a contrast ratio for a gold we do not ship. Both are open. Neither would have been found by reading the code again.
Desktop, web and mobile are all on 0.5.9. The full list is in the changelog, and the desktop app updates itself.
References and further reading
- Foxl changelogRelease
- Foxl CodeReference
- Download FoxlReference