TRAINYOURAGENT

Coast: a GLP-1 companion app where seven crisis categories never reach the model

We built a native iOS app on SwiftUI, SwiftData and Supabase: real StoreKit 2 subscriptions across four product IDs with a transaction listener and Keychain-cached entitlements, an LLM coach, and a hardcoded regex guard that intercepts seven categories of medical and psychiatric crisis before any message can reach the model.

The problem

GLP-1 medications changed what a health-tracking app has to be. The daily questions are not 'how many steps' — they are 'is this nausea normal', 'I have not eaten since yesterday, is that a problem', 'my dose changed and I feel strange'. A useful app has to answer those conversationally, which means a language model, which means the app is now one prompt away from giving medical advice to someone describing pancreatitis. The lazy solution is a system prompt that says 'you are not a doctor'. That is not a safety control. It is a request, sent to a probabilistic system, that the probabilistic system is free to decline under sufficiently unusual input — and crisis messages are, by definition, unusual input. The second problem is commercial and unglamorous: subscription code. Most indie iOS apps ship a paywall that checks entitlement once at launch, which breaks on every reinstall, every family-sharing edge case, and every offline start. Getting StoreKit 2 right is a surprisingly large amount of the work in a small app.

The safety rail runs before the model, not after it

`RedFlagGuard.swift` is 62 lines of deliberately boring Swift: an ordered list of regular expressions mapped to seven `RedFlagKind` cases — anaphylaxis, suicidal ideation, pancreatitis, persistent vomiting, sudden vision change, hypoglycaemia, and pregnancy. Its file header states the contract in capitals: it always runs before any Groq or LLM call, and on any match the caller must render the hardcoded escalation sheet and must not forward the message to the LLM under any circumstances. The ordering is itself a safety decision. Anaphylaxis and suicidal ideation are checked first, so that a message containing a co-occurring keyword cannot bury them behind a lower-priority match. A user who writes 'I can't breathe and I feel like I want to end my life' hits the two highest-priority rules, not the nausea rule. This is a deliberately dumb component and it should stay dumb. The whole value of a hardcoded guard is that its behaviour does not vary with model version, temperature, prompt injection, or a user phrasing something in an unusual language register. It is a regex. It cannot be talked out of firing. The complementary control is `NetworkAllowlist.swift`, which constrains where the app is permitted to make outbound calls at all — so a compromised or misconfigured code path cannot quietly send health data to a host nobody vetted.

StoreKit 2 done properly: four products, a live listener, a Keychain mirror

`StoreKitManager.swift` is 405 lines and its header states the design: listen to `Transaction.updates` for the lifetime of the app, resolve current entitlements on cold start without calling `AppStore.sync`, and mirror the tier and expiry to the Keychain so that reinstalls retain the tier offline. Four product identifiers back two tiers on monthly and annual terms: `Coast.lifestyle.monthly`, `Coast.lifestyle.annual`, `Coast.clinical.monthly`, `Coast.clinical.annual`. A `tier(for:)` mapping resolves an identifier to a tier, and where multiple entitlements are simultaneously active, clinical outranks lifestyle — which is the correct resolution when a user upgrades mid-term and both receipts are briefly valid. The Keychain mirror is the piece that matters most in practice. Keychain storage is per-device rather than per-install, so a user who deletes and reinstalls the app sees their correct tier immediately, before any network round trip, and a user with no signal at all still gets what they paid for. The code notes a deliberate second path as well: even if the Keychain is wiped, the entitlement row on the backend can restore the tier — so there are two independent recovery routes rather than one. It also avoids `AppStore.sync` on cold start, which is the right call: `sync` prompts the user for their Apple ID password and is only appropriate behind an explicit 'Restore Purchases' action.

Supabase with an audited RLS surface and a smoke test

The backend is Supabase Postgres with six migrations, and — unusually for a project this size — the security posture is documented and testable rather than assumed. `supabase/RLS_AUDIT.md` records the row-level-security review, and `supabase/test_rls.sql` is an executable smoke test that exercises the policies rather than trusting that they were written correctly. For an app holding weight, dose and symptom history, this is the whole ballgame. An RLS misconfiguration in a health app is not a bug class, it is a disclosure event, and the standard failure is not a missing policy — it is a policy that exists and does not do what its author believed. A SQL smoke test that attempts a cross-tenant read and asserts it fails is worth more than a paragraph in a README saying RLS is enabled.

The numbers, and the command behind each one

What is genuinely hard about this

Writing a safety guard that fires reliably without firing constantly. Every rule in `RedFlagGuard` is a trade between false negatives — which are dangerous — and false positives, which are merely annoying but which, at volume, train users to dismiss the escalation sheet without reading it. A guard everyone learns to swipe away is worse than no guard, because it produces the appearance of a control. That is why the vomiting rule requires a duration qualifier ('for 24', 'for more than', 'for a day') rather than matching the bare word. Getting entitlement state correct across the real matrix of situations: cold start offline, reinstall, upgrade mid-term with two active receipts, refund, family sharing, and expiry while the app is backgrounded. Each is individually easy and the combination is where subscription code goes wrong. The architecture here — a lifetime `Transaction.updates` listener, entitlement resolution without `AppStore.sync`, a Keychain mirror, and a backend row as a third source — exists specifically to make each of those cases resolve to the same answer. And the ordinary hard thing: the LLM coach has to be genuinely useful in the ninety-nine percent case while being structurally incapable of participating in the one percent case. Those two goals pull against each other on every prompt, and the only way we know to hold both is to put the safety decision somewhere the model cannot reach.

What this entry does not claim

Stack and status

Built with Swift / SwiftUI, SwiftData, StoreKit 2, Keychain Services, Supabase (Postgres + RLS), Groq (vision + LLM) and Xcode build-phase checks. Relationship: Internal build. Stage: Built, not launched. Period: 2026. Our role: iOS app, subscription infrastructure, safety architecture, RLS audit Figures re-derived on 2026-08-23.