Same medication tracker as last time, different day, different problem. This one isn’t about the app going dark. It’s about everything technically working and still being wrong.
147 tests. Nearly 2,000 lines of Kotlin. Everything green. I deployed it, used it for real for about ten minutes, and found four separate ways it was lying to me, none of which a single one of those 147 tests could ever have caught, because they weren’t bugs in what the code did. They were bugs in what the running app was telling a human standing in front of it.
Arithmetic first, because the rest doesn’t matter if this is wrong
Before any database, any HTTP, anything, I wrote the days-remaining math as plain Kotlin with zero dependencies, because if this number’s wrong, the app isn’t useless, it’s actively dangerous. Confidently wrong about whether you’re about to run out of insulin is worse than not having an app at all.
The spec had it as two separate divisions to average weekly and as-needed doses onto one timeline. I collapsed both into one exact fraction, floored once, instead of doing the math in floating point:
days_remaining = floor( units * 210 / (weekly * 30 + prn_total * 7) )
Not being clever for the sake of it. Binary floating point flat-out gets this wrong on exact boundaries: 4.5 units on hand, 4.5 used weekly, the honest answer is 7 days, and a Double hands you 6. I swept roughly 56,000 schedule combinations against exact rational math to check. About 0.2% disagreed, always off by exactly one, always sitting right on a boundary. Rare enough to sail through code review unnoticed. Common enough to flip a real alert on a real person.
There’s now a test that asserts the naive floating-point version returns the wrong number on purpose, so future-me, or anyone else who thinks they’re “simplifying” this, finds out immediately why it’s written the ugly way.
Breaking my own tests on purpose
Here’s the practice that earned its keep hardest, and it’s embarrassingly simple: for anything a test claims to guard, break the actual mechanism on purpose and confirm the test goes red. Then put it back.
Sounds like paranoid busywork. It caught four tests that were passing for entirely the wrong reason, quietly guarding nothing, forever, until I forced the question.
Moved the error-catch inside the transaction. Ten repeat taps took stock down to 50 instead of 59. SQLite aborts the one bad statement, not the whole transaction, so a failed deduction silently committed without its matching dose row. Three tests caught it, that’s a test doing its job.
Deleted the HTTP method guard in the service worker. Every test still passed. Turns out every write case in the test suite happened to route through /api and got caught by a completely different check, so that guard was dead weight nobody would’ve noticed missing until it mattered.
Deleted the offline navigation fallback. Still green. The tests requested /, which is precached and always resolves fine, but Android tacks a query string onto start_url on a real home-screen launch, and cache matching compares the full URL including that string. A real phone launch would’ve missed the cache entirely and gotten nothing, while every single test sailed past it.
Disabled the SQLite foreign-key pragma. Exactly one test failed, which is the correct outcome, and the one I actually wanted to see. Foreign keys are off by default in SQLite, enabled per-connection, and any connection that forgets the pragma just quietly accepts orphaned rows forever.
Two of those four had the actual mechanism removed and the suite stayed green. A green test suite proves the tests ran. It proves nothing about the code until you’ve personally watched each one fail for the reason it claims to exist.
Things only the real server was rude enough to tell me
Ran clean locally. Deployed to the actual box and immediately hit three problems no amount of local testing was ever going to surface.
A tmpfs mounted noexec. First run died instantly: UnsatisfiedLinkError: failed to map segment from shared object. The SQLite driver extracts its native library to the JVM temp directory and loads it from there. Docker mounts --tmpfs noexec by default, so the load just failed on the very first database open. The error message says nothing whatsoever about mount flags. /tmp is now mounted exec, with a comment explaining exactly why, because the “obvious” future hardening move is a crash loop.
A port that was already spoken for. Installing to a phone needs a service worker, which needs a secure context: HTTPS or bust. Plain HTTP over the tailnet serves the app fine and just silently never offers to install, no warning. So: real cert via Tailscale Serve, which defaults to 443, except 443 on that box was already doing something completely unrelated. Publishing there would’ve shadowed an existing service’s cert and taken it down as a side effect of deploying a medication app, which is the kind of blast radius nobody signs up for on purpose. Moved to a different port. Costs nothing, a secure context cares about scheme, not port number.
Compose stacks that fail without telling anyone. On that server, containers aren’t wrapped in systemd units, they lean entirely on Docker’s own restart policy. Which means a crash-looping container restarts forever, silently, and nothing ever reports it. I’d written that exact fact down months earlier and completely forgotten I had. A dead media server is obvious the second you try to watch something. A dead medication tracker is invisible until the specific morning you go to check it and it isn’t there.
Fix ended up being the nightly low-stock alert script, since that one does run as a proper systemd timer with real failure notification wired up, but only if it’s built to actually notice a dead server instead of politely assuming an empty response means everything’s fine:
ALERTS="$(curl -fsS --max-time 20 "$API/api/alerts")"
That -f flag is doing the entire job. Without it, a dead container hands back an error page, the script parses zero alerts out of it, and reports “all clear” every single night the app is down. Silence dressed up as reassurance. The gap between a real alerting system and one that just resembles one was a single missing character in a shell script.
I didn’t just trust that. Stopped the container, ran the check by hand, watched it die with exit 7, watched systemd catch the failure, watched the notification land on my phone. Good time to prove that chain: database was still empty.
What ten minutes of actually using it found
Everything above, tested and proven. Then I installed it for real, put in my actual medications, and tapped “take all” for the morning. Four bugs inside ten minutes, and not one of them was about what the code computed. Every single one was about what the app told a person looking at the screen.
Offline hung instead of failing. Airplane mode, app opens to its splash screen, and just… stays there. Forever. No app, no error, nothing. An unreachable server on a tailnet doesn’t always politely refuse the connection. The name still resolves, nothing rejects the socket, the fetch just never resolves either way. My service worker was awaiting it with zero timeout, so the whole page had nothing to render and nothing to say. Every network call now has an actual number on it. Reads give up after 4 seconds since someone’s standing there waiting and a read is cheap to retry, writes get 8 because a timed-out write might have landed and the only honest move is to make you go check.
A blank screen making a claim it hadn’t earned. With the hang fixed, it correctly showed a red “can’t reach server” banner, sitting directly above a completely empty medication list. Which is the single worst thing this specific app is allowed to display, because an empty list and “nothing due today” are visually identical, and one of those two things at six in the morning is dangerous to get wrong. Fixed: no list is ever blank by default now. Loading says loading. Failure says failure, explicitly, as “can’t reach the server,” never silently rendered as an empty day.
Doses landing on the wrong calendar day. Tapped “take all,” and it logged against yesterday. Today then cheerfully offered to let me take everything again, since as far as it could tell, nothing had happened yet. The write itself was fine, stock even decremented correctly, the client had simply sent the wrong date. I never fully pinned down how it drifted onto the wrong day, so the fix targets the whole category rather than one cause: a loud banner when you’re not viewing today, a confirmation before bulk-logging against a past date, and the fix that’ll actually matter, following the real date across a midnight rollover on resume, since this is an app that gets opened right before bed and first thing in the morning.
The fourth bug that day was the app going completely dark on every device while the server was perfectly healthy. That one got its own post, The Server Was Never Down.
The default I refused to ship
Last thing that came out of actually using it: an as-needed medication was projecting 690 days remaining on a bottle of ibuprofen. Technically derived correctly from the math. Read as complete nonsense the second a human looked at it, because a rate based on whatever happened to occur in the last thirty days swings wildly on a single dose and dresses that swing up as precision it doesn’t have.
Fix: as-needed medications alert on raw units left instead of a days projection. That also patched a hole nobody had noticed. Under the days-based rule, an as-needed medication with nothing logged in the past month has no projection at all, and a null projection never alerts, so it could sit at two tablets left and say absolutely nothing about it.
The migration wanted a sensible default threshold backfilled onto every existing as-needed medication, ten units seemed reasonable. Checked it against the real data before shipping it, because “seemed reasonable” is not a threshold, it’s a guess wearing a lab coat:
Gabapentin 64 units
Lispro (Insulin) 1385 units <- 10-unit threshold
A ten-unit warning on 1385 units of insulin would functionally never fire, on the exact medication my own original spec singled out by name as the one where running short is not a minor inconvenience. Ten’s fine for a bottle of tablets and dangerously wrong for that. There is no single default that’s safe across both, so the migration adds the column and backfills nothing. Existing meds keep their days-based threshold until a real number gets chosen for each one, and the reasoning’s written directly into the migration file so nobody “helpfully” finishes the job for me later without reading why it was left undone.
Worth naming the general shape of that mistake: a default is a decision made on someone else’s behalf without knowing their actual situation. Usually that’s a harmless convenience. The moment that number is the thing deciding whether a warning fires at all, it stops being convenient and starts being a bet you didn’t ask permission to make.
Breaking my own rule, on purpose, in writing
That change directly contradicted a line in my own spec, filed under a heading that literally said settled, do not reopen during implementation. That rule exists for a good reason: it’s there to stop you relitigating design decisions mid-build out of boredom or anxiety.
But that rule was written before the thing existed, and actually running it produced evidence that plain didn’t exist at the time I wrote the rule down. So I reopened it anyway, and documented in the spec itself that it was reopened, when, and specifically why, instead of just quietly contradicting a document I’d told myself not to touch. The original math was never wrong. It was unhelpful in a way you can only discover from the far side of a system that actually runs.
147 tests, all green, the whole time. None of them were ever going to catch a number that was correct and useless, a screen that was blank and honest at the same time, or a default that was reasonable for one drug and reckless for another. That’s not a gap in the test suite. It’s the actual, permanent boundary of what a test suite can tell you: it verifies what code does. Only running the thing in front of an actual person tells you what it means to them.
