How the app actually works, at the level of files, functions, thresholds, SQL and data flow — read from the live code (iOS + Supabase backend) and the production database. Built as the reference for coding Allin from a fresh environment.
Allin is an iOS wellbeing app: a daily emotional check-in routes the user to a matched guided-audio "rewire" session, plus a 10-step beliefs plan, an audio library and a journal.
Maelwi/allin-ios → ~/Documents/allin-gh/allin-ios, branch main. Bundle com.allinwellltd.allin, App Store ID 6754271583, v2.2(5).wsxpmcbtcknewpiwudsc (us-east-1) — repo Maelwi/allin-backend → ~/Documents/allin-backend. Postgres + Auth (Sign in with Apple) + Storage (bucket audio-sessions) + 2 Deno edge functions.#if canImport(...) — compiles + no-ops until the packages/keys are present.The loop: check in (emotion → situation → intensity+body → intent) → routed to a matched audio → reflect (pre/post mood + note) → journal/streak. The plan is a parallel 10-step path with the same reflect mechanic per step.
Entry point (note: type is named FixingApp, a leftover). init() runs a one-time v1.6 UserDefaults migration (removes 8 stale beliefsPlan_* keys, guarded by "v16_userDefaults_cleaned"), installs a 50MB/500MB URLCache (disk path audio-cache) so Storage mp3s cache, then fires AnalyticsClient.configure() (:45), SubscriptionManager.configure() (:52), EntitlementService.refreshTrialBadge() (:59), and re-arms the daily check-in notification from allin.ritual.* defaults.
Boots the Meta SDK (MetaAdsClient.configure), sets the notification-center delegate, calls registerForRemoteNotifications() every cold launch. APNs token → AnalyticsClient.registerDeviceToken (→ Segment → Customer.io push). Notification taps parse DeepLink.fromNotification, track notification_opened, and set DeepLinkRouter.shared.pending.
Core/AuthenticationManager.swift — Apple Sign In via Supabase, session restore, and identity fan-out.
Builds an ASAuthorizationAppleIDProvider request with requestedScopes = [] — no name/email scope requested. On success (didCompleteWithAuthorization, :149) extracts the Apple identityToken and calls auth.signInWithIdToken(provider:.apple, idToken:) (:162). Then identifies analytics + RevenueCat, tracks sign_in_succeeded, and fires Meta trackCompleteRegistration(method:"apple").
init reads auth.currentSession synchronously (Keychain, no network) and identifies immediately; a background restoreSession() refreshes the token but only clears auth if currentUser == nil — a transient refresh error never wipes a valid cached session (this is a deliberate fix for the "logged-out on cold launch" class of bug).
Deletes the user's emotional_sessions, then calls RPC delete_user (removes the auth user; cascades — see §14). Cleanup then mirrors sign-out. RPC failure is caught so sign-out still happens. Note: erasure is not propagated to Segment/Amplitude/CIO/Meta (only local reset()).
Core/AppView.swift is the hub: it owns every top-level @StateObject (auth, sessions, plan, audio library) and a currentScreen: AppScreen switch across ~15 screens, and it hosts the whole check-in → recommendation → audio → reflection flow.
The routing brain. Sets checkinProcessing, tracks checkin_completed {intent,intensity}, builds a routing catalog from the live audio_library (only slugs resolving to playable files, else bundled AudioCatalog.all), then in a Task: saves the session and calls CheckinRouter.route(checkIn, catalog:). A checkinGeneration counter drops stale results if the user navigated away; safety_handoff → CrisisSupport; otherwise resolves the routed id → library row and shows checkinOverlay = .recommendation (curated description preferred over the AI note).
Persists the session; if the origin was the beliefs plan, computes shift = clamp(post − pre + 5, 0...10), calls beliefsPlanManager.completeStep(...), tracks plan_step_completed, and on a fully-done block tracks plan_block_completed + auto-calls continueJourney().
Other key handlers: applyPendingDeepLinkIfReady() (:403, waits for auth+onboarding+library before applying a deep link), handleStartCheckIn() (:512), handleSessionComplete() (:623, carries reframe+reflectionNotes through the completion upsert — fixes an earlier NULLing bug), checkForceUpgrade() (:718, vs app_config min version, fails open).
AuthenticationManager twice at launch (the line-4 default + the init() reassignment), so two cached-session reads + two background restores fire. The line-4 default is dead. Also the case .recommendation: in the screen switch (:105) is unreachable — the live recommendation renders via checkinOverlay, not currentScreen.Home/EmotionalCheckIn.swift is a step state-machine whose step list changes by lane.
enum Step { emotion, situation, intensity, intent, // universal core
consent, belief, pattern, origin, kinder, // Unblock tail
claritySubTap, confidenceSubTap, embodyNew } // Clarity/Confidence tails
Core is always [emotion, situation, intensity, intent]; then it forks on AudioLane(selectedIntent): soothe → (none); clarity → +claritySubTap; embody → +[confidenceSubTap, embodyNew]; unblock/default → +[consent, belief, pattern, origin, kinder]. Indices are clamped with min(currentStep, steps.count-1) because the list length changes mid-flow.
handleNext() (:849)emotion/belief): if crisisDetected(crisisText()) { onCrisis() }. On situation it runs an async layered check (crisisGateThenAdvance).if step == .intensity && Int(intensity) >= 7 → checkExpressOrAdvance: classifies valence, and if .unpleasant completes immediately (skipping the intent fork) so the router forces soothe-first; if .pleasant it advances to the fork.>= 7 and the slider defaults to 7 (intensity = 7, :36) — so a user who never moves the slider sits exactly on the boundary. Marie's intent is >= 8. One-line change, needs an app build (§15).Core/CheckinRouter.swift — static func route(_ c: CheckIn, catalog:) async -> Recommendation (:165). Gate order:
crisisDetected(allText) → safety_handoff, no network.async let the euphemism classifier and the affect classifier; if crisis → handoff.mustSootheFirst = (c.intensity >= 7 && valence == .unpleasant) → activeLane = mustSootheFirst ? .soothe : intentLane. Authoritative gate, threshold 7.Candidate resolution: filter catalog by activeLane, then lane-narrow (soothe → sleep-need if mentionsSleep else emotion-family match; clarity/embody → sub-tap → need map). 1 candidate → use it; 0 → lane default; blank situation → default with no AI call; else AIRouter.pick POSTs candidates to edge fn /recommend-audio and validates the returned id is in-set (else falls back to default).
POSTs to edge fn /classify-affect (20s). On any failure → offlineFallback: a keyword heuristic over 8 families, defaulting to .anxious; valence is .pleasant only for settled/energised with no distress words, else .unpleasant ("fail safe toward soothing").
return json(toolUse.input), unguarded. This was reported as fixed twice without ever confirming the deploy actually happened. The bug that lets an out-of-schema model output silently corrupt routing is active in production right now, on every check-in. Highest-priority one-function redeploy on the whole list — low risk, already written, just never shipped.Three independent layers; any one triggers the support hand-off. High-recall by design.
crisisDetected() substring-matches a 21-phrase list ("kill myself", "want to die", "self harm", "overdose", …). Scans only free-text (customEmotion, triggerText, customBelief) — chips aren't crisis signal.CrisisClassifier.flags() POSTs to /classify-crisis for euphemisms; network failure → false (layers A + C still cover).safety_handoff when no audio resolves.CrisisResources.forCurrentRegion() (region from Locale.current.region): US/CA 988, GB/IE Samaritans 116 123, AU Lifeline 13 11 14, NZ 1737, else findahelpline.com; plus a constant emergency line (911/999/000/111/112). Tap-to-dial via UIApplication.open. CrisisSupportView shows no audio. Gap: no UK Shout 85258 text line.
Two matchers coexist: the new router (primary) and a legacy keyword matcher (Core/AudioLibraryStore.swift) used as fallback when the routed audio isn't playable.
Legacy soothe threshold is 8 (intensity = 11 − preMood; if intensity >= 8 { return .soothe }). scenarioMatch (:163) is an ordered keyword router (betrayal/breakup/heartbreak/conflict/work-dread/family-trigger…) returning specific slugs; generalMatch (:249) falls back through emotion/belief clusters (clusterMap from backend checkin_config).
Streams from Supabase Storage into the URLCache; a static weak activePlayer guarantees one audio at a time. Progress timer at 0.5s. Completion fires only on natural 100% finish → onComplete(true, 100) → tracks audio_session_completed + RPC record_audio_session_complete. On onAppear, EntitlementService.canStartAudioSession() gates: .allowed → play; .paywall → track audio_session_paywalled + show PaywallView.
AudioLibraryStore uses 8. Since legacy is only fallback, the effective user-facing gate is 7. And completion is all-or-nothing — a 90%-then-exit play consumes no trial/weekly entitlement and logs no completion.iOS BeliefsPlanManager (cache) → BeliefsPlanService (I/O) → edge fn recommend-plan (composition).
x-allin-claude-plan: 1 — that was documented correctly right here the whole time — but the summary sentence and §15's "dead LLM code" flag directly contradicted it without anyone cross-checking. The live app (v2.2/b5, and every version since v2.1(4) which added the header) takes the Claude/Anthropic composition path, not FTS. FTS is the fallback when Claude errors or its output fails validation, not the primary path. The mistake traces to a stale in-source comment referencing app version "1.9.2" (several versions behind current) that was never updated when the header shipped.Wraps a background task; POSTs {baseURL}/recommend-plan with the JWT + header x-allin-claude-plan: 1, 90s timeout. Body = camelCase BeliefsIntakeBody (3 areas + ratings + 4 free-text fields each). Maps status codes to typed BeliefsPlanError (400/401/500/502/timeout/offline). Custom decoder strips 6-digit Postgres microseconds before ISO8601 parse.
recommend-plan/index.tsService-role client validates the JWT via getUser(token) (the fn is deployed --no-verify-jwt, so this is the only auth gate). validateIntake requires exactly 3 areas with all 4 text fields. Then, since useClaude is true for the live app (:241): buildClaudePlan POSTs the full intake + audio catalog to api.anthropic.com using SYSTEM_INSTRUCTIONS, forcing a submit_plan tool call; the result is validated (validatePlan) and used if it passes. Only on a Claude error or a validation failure does it fall back to buildFTSPlan:
allocateSteps: rating→steps (r≤3→4, ≤6→3, else 2), adjust to total 10; sequenceStepAreas interleaves so areas don't clump.buildAreaQuery concatenates the 4 fields, strips stop-words, OR-joins tokens → RPC search_audio_library(query_text, 20) → keep only candidates whose cluster ∈ AREA_CLUSTERS[area] (structured gate); fallbacks = cluster-mapped, then any non-situational.step_type alternates reset/rewire by parity; repeats allowed; final fallback slug unblocking_empowerment_legacy.is_active=false, inserts beliefs_plans (intake_json) + 10 beliefs_plan_steps, re-selects joined with audio_library, fires server event plan_generated.stuck vs stuckness seed drift. Code (AREA_CLUSTERS) + checkin_config.cluster_map use cluster "stuck", but seed/audio_metadata.json still tags 8 audios "stuckness". The DB was hand-aligned to "stuck", so re-running the seed job would revert those 8 rows and silently break plan matching for Career/Intellectual/Financial/Creative areas (they'd drop to the "any audio" fallback). Fix: correct the seed JSON to "stuck".Singleton client (hardcoded URL + anon JWT). updateEmotionalSession is an UPSERT on id (:196) — library/plan/replay paths never inserted a row first, so a plain update would silently drop the entry. withRetry (3×, 0.4/0.8s backoff) guards writes only. fetchCheckinConfig reads checkin_config key 'v2' (live app reads 'default') so editing new chips never touches production.
Guards every load/save on authManager.currentUser and never clears on a transient nil. updateSession is optimistic (local copy first). currentStreak (:225) walks distinct active days with a 30-day grace window (non-punitive). Only clearSessions() (on sign-out) empties the list.
iOS Segment SDK + backend edge fns → Segment "Allin iOS" source → Amplitude + Customer.io (EU) + Meta.
AnalyticsClient (Segment): configure() with lifecycle-tracking on, flushAt 20 / interval 30s. identify(userId, email) on sign-in; track/screen; registerDeviceToken → CIO push. iOS must not fire plan_generated/_failed — the backend emits those (double-count guard).MetaAdsClient: configure enables auto-app-events + IDFA collection at launch; ATT (requestTrackingAuthorization) is only called from the consent sheet (not at launch). trackPurchase, trackCompleteRegistration. trackStartTrial() is defined but unused._shared/segment.ts (server): fetch-only track (no identify); no-op if SEGMENT_WRITE_KEY unset; swallows all errors. Server events: plan_generated, plan_generation_failed, and from revenuecat-webhook the subscription_* family.Entitlement id "allin Pro". configure(appUserId:), identify → logIn(userId), purchase(tier:) fans out subscribe_succeeded (Segment) + trackPurchase (Meta), distinct from the server webhook's subscription_started to avoid double-count. Annual footnote computes "50% off" dynamically from the store price.
Entitlement RPCs (live SQL, §13/§14): can_start_audio_session() — active sub → unlimited; trial_audio_sessions_used < 10 → trial; else rolling-7-day free; else paywall. record_audio_session_complete() increments the counter / stamps last_free_audio_at. iOS EntitlementService fails open (rpc_error_fail_open) so an infra hiccup never walls a user.
revenuecat-webhook is idempotent (via revenuecat_last_event_id) but is NOT in the CI deploy — it must be deployed manually.9 migrations under supabase/migrations/. Highlights: audio_library (+ GIN tag indexes), beliefs_plans/_steps (RLS own-rows, cascade to auth.users), storage bucket audio-sessions, the FTS search_vector, user_entitlements (+ entitlement RPCs + triggers), an emotional_sessions reframe column patch, the situational cluster data-migration, and checkin_config.
-- delete_user(): cascading erase (auth.users delete cascades to all user tables)
delete from public.emotional_sessions where user_id = auth.uid();
delete from public.users where id = auth.uid();
delete from auth.users where id = auth.uid();
-- search_audio_library(query_text, limit_count=10): ranked FTS
SELECT * FROM audio_library
WHERE search_vector @@ websearch_to_tsquery('english', query_text)
ORDER BY ts_rank(search_vector, websearch_to_tsquery('english', query_text)) DESC, created_at DESC
LIMIT limit_count;
Also live but unused by the app yet: record_consent_granted() and has_granted_consent() exist (they write/read user_entitlements.consent_granted_at) — the durable consent record the GDPR plan calls for is already scaffolded in the DB, just not wired from iOS. handle_new_user() + ensure_user_entitlement() triggers auto-create users + user_entitlements rows on signup.
Push to main, path-filtered jobs (parallel): migrations/** → supabase db push; per-function path filters (fixed 2026-08-14, was one hardcoded step deploying only recommend-plan on any function change) → each of recommend-plan/recommend-audio/classify-affect/classify-crisis deploys only when its own directory changes; seed/audio_metadata.json → node scripts/seed.mjs (PostgREST upsert on slug). Auth via repo secrets SUPABASE_ACCESS_TOKEN/_DB_PASSWORD/_SERVICE_ROLE_KEY.
recommend-plan (this Claude-belief_theme-matching version) was live on Supabase at v31, but main in this repo still had the pre-redesign keyword-matching version — nobody had ever committed the real one. Because the old CI redeployed recommend-plan on any function change, the next unrelated push (e.g. the GDPR erasure branch) would have silently overwritten the live recommender with the stale one for every user. Fixed by pulling the real source via supabase functions download (the Management API's function-body endpoint returns an ESZIP2 binary bundle, not source) and hardening CI as described above. Zero behaviour change to production — a source-control fix only.| Table | Holds | RLS |
|---|---|---|
| emotional_sessions | emotion, body, trigger, belief, memory, desired/new belief, reframe text[], pre/post mood, reflection_notes, audio_completed/progress | ALL where auth.uid()=user_id |
| beliefs_plans | intake_json jsonb, llm_reasoning (unused), is_active | own read/insert/update |
| beliefs_plan_steps | step_number 1–10, audio_id, step_type (reset/rewire), pre/post mood, shift_rating, feedback_text, completed | own read/insert/update |
| user_entitlements | subscription_status, expires_at, revenuecat_app_user_id, trial_audio_sessions_used, last_free_audio_at, consent_granted_at | own read (writes via SECURITY DEFINER RPCs) |
| audio_library | slug, title, filenames, duration, cluster, 3 tag arrays, search_vector (generated, GIN) | public read |
| checkin_config · app_config | chip lists + cluster_map (jsonb) · min version | public read |
| users / auth.users | id + email (Apple) | own profile |
Every user-owned table has ON DELETE CASCADE to auth.users, so delete_user() fully erases the database (verified). Special-category (mental-health) free-text lives in emotional_sessions, beliefs_plans.intake_json, and beliefs_plan_steps.feedback_text — the targets for the GDPR pseudonymisation/encryption work.
>= 7 (EmotionalCheckIn.swift:871, CheckinRouter.swift:185); legacy AudioLibraryStore used 8. Slider defaulted to 7 → express fired by default. FIXED 2026-08-14 on branch fix/apple-signin-email-scope (not live yet, needs a build): threshold changed to >= 8 app-wide.stuck/stuckness seed drift (§9) — re-seeding silently breaks plan matching for 4 life areas. Still open — fix the seed JSON.SYSTEM_INSTRUCTIONS, formatAudioCatalog, ANTHROPIC_* env are all live and called on every plan generation by the current app (see §9's correction note) — only formatUserIntake and parseClaudePlan are genuinely unused (superseded by tool-use-based output). This means beliefs-plan intake free text is sent to Anthropic, which isn't reflected in the DPIA/ROPA until this same audit corrected those too — see the GDPR wiki's dpia/ropa entries.revenuecat-webhook not in CI deploy — still redeploy manually. Double AuthenticationManager construction + unreachable .recommendation screen case (§4). All-or-nothing audio completion (§8). trackStartTrial() unused. All still open.record_consent_granted RPC exists, unused); no age gate (still open); UK Shout 85258 text line added (fixed, same branch, not live).No secret values here — locations only. Full values live in your password manager / the vaults below.
| Secret | Where it lives |
|---|---|
| Supabase anon key | Hardcoded in SupabaseService.swift (public by design) |
| Supabase service-role key | Supabase project secrets + GitHub Actions secret (server only) |
| Supabase Personal Access Token | macOS Keychain — service "Supabase CLI" |
| SEGMENT_WRITE_KEY | iOS Info.plist + Supabase project secret |
| Facebook app id / client token | iOS Info.plist |
| RevenueCat public SDK key | iOS Info.plist (RevenueCatApiKey); secret key in RC dashboard |
| REVENUECAT_WEBHOOK_SECRET · ANTHROPIC_API_KEY | Supabase project secrets (edge fns) |
| ASC API key (.p8) · APNs .p8 | Build Mac (~/.appstoreconnect) · uploaded to Customer.io for push |
| Sign in with Apple key | Apple Developer (key id P6GGA74424) |