Two of this week's commits exist because of a change I made a month ago and liked at the time. Moving Uncaption's transcription from AWS Transcribe to Whisper was a clear win — seconds instead of minutes — and it quietly invalidated two things built around the old shape: the metering model, and every assumption about what an upstream error looks like when it reaches a user. Both came due at once.
The Alert That Showed Someone the End of My API Key
The proxy's OpenAI key expired. OpenAI returned a 401 with a body explaining which key had failed, and the proxy relayed that body verbatim, and the iOS client rendered it straight into a user-facing alert. So a user got an alert containing the tail of my API key and a link to my OpenAI account.
Nothing about that is exotic. WhisperError.errorDescription interpolated the server's message because during development the server's message was the most useful thing on screen. It stays the most useful thing on screen right up until the server says something that isn't the user's business.
The fix is two-sided, because either half alone is a half-fix. On the client, errorDescription now returns localized generic copy and never interpolates anything from the network; a separate diagnosticDescription carries the status code and raw body into the logs, where the detail is genuinely useful. On the proxy, responses now carry a generic message plus a machine-readable code — unauthorized, bad_request, rate_limited, upstream_error — and upstream statuses get remapped, so a broken server key surfaces as a 502 instead of a 401 the app would misread as the user's own auth problem.
The proxy change also dropped two log fields I'd forgotten were derived from live secrets: the OpenAI key prefix written on every successful request, and the shared-secret prefix and length written on every auth failure. Neither was ever going to reach a user; both were sitting in logs for no reason, which is its own category of exposure. The handler is wrapped in a try/catch now too, so a formData, fetch or JSON parse failure returns the same sanitized 502 rather than an unhandled 500 with a stack trace attached.
I Deleted the Meter and Deleted the Paywall With It
The minutes economy was built for AWS Transcribe, where a job took one to two minutes and per-minute accounting corresponded to something real. Whisper returns in seconds. The accounting had stopped describing anything, so out it went: SubscriptionMinutesService deleted, minutes labels stripped from the account screen, the paywall and onboarding, six stale keys removed from all eight Localizable.strings files, and copy updated from "in minutes" to "automatically." 95 lines added, 483 deleted, which is the good direction.
Then I actually read what was left. canUserProccessVideo() returned an unconditional true.
That's the whole bug. The minutes balance was the paywall — remove it and there is no gate at all, just any user running unlimited Whisper jobs against a proxy I pay for. The function now reports the RevenueCat "pro" entitlement instead of a hardcoded true, and a few things around it had to change to make that not feel broken: tapping the action button without an entitlement re-checks live before presenting the paywall, so the first tap isn't rejected merely because the initial load hadn't returned yet, and reloadUserAccount() refreshes status so dismissing the paywall after a purchase unlocks the button in place. Splitting the post-gate work into continueTranscriptionFlow() was the piece I nearly missed — without it, an ungated tap fell through both branches and left the button completely inert.
Completed transcriptions and resumed in-flight AWS jobs stay ungated. Those were paid for under the old model and it would be theft to re-charge for them.
Worth being honest about the ceiling here: this is a client-side gate. The proxy still accepts any request bearing the shared secret compiled into the binary. It restores the revenue model; it does not cap what I can be billed by someone who pulls that secret out of the app. That's a different piece of work and I'd rather name it than pretend the entitlement check solved it.
Sleep Beats: Build It, Don't Download It
Small week, both items about telling the truth to a machine or a user. Journey Studio's installer used to fetch the GitHub release asset, which arrives quarantined, so every update meant an xattr incantation before macOS would open it. The source is already in the checkout — building locally is current by construction and produces a bundle Gatekeeper never flags. That "damaged and can't be opened" failure shipped because verification used codesign -dv, which cheerfully reported "adhoc" for an unsigned bundle. It's --verify --deep --strict now, which actually fails.
The other item: build 30's release notes, in all eight locales, still described build 29 — backdrop mode, the new languages, the brainwave-band guide. Build 30's user-visible change is that Pro unlocks immediately and offline. The entitlement cache deserved to be the headline rather than a footnote, so it is.
A Repo Before Any Code
New project got its scaffolding: Travel Social Planner, a monorepo with an Expo mobile app and a TypeScript server, both of which are currently empty directories. The premise is that saving travel content from Instagram and TikTok is easy and using it later is not — so the app takes a shared post, extracts the useful travel information, resolves real places, and keeps the creator's context rather than just a map pin.
511 lines across eight files, none of it implementation. docs/ holds PRODUCT, ROADMAP and ARCHITECTURE as a working copy of the Notion page, each carrying a Last synced date, with Notion staying the source of truth. CLAUDE.md keeps an explicit "Not decided yet" list so the gaps are legible to whoever — or whatever — picks the work up next.
Committing a spec before any code feels like procrastination and isn't. The alternative is reconstructing every decision later from the code that resulted from it, which is exactly the reading direction that produced this week's other two commits.