Allin — Engineering deep dive

Allin — engineering deep dive

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.

iOS SwiftUI · Supabase (Postgres + Deno edge fns) · v2.2 (b5) · read Aug 2026 · repos Maelwi/allin-ios · allin-backend

1Stack & the core loop 2Launch & app entry 3Auth & identity 4AppView — the orchestrator 5Check-in flow & gates 6The router & classifiers 7Crisis detection 8Audio selection & playback 9Beliefs plan engine 10Data layer & sessions 11Analytics & lifecycle 12Subscriptions & entitlements 13Backend: migrations, RPCs, CI 14Data model & RLS 15Known bugs & dead code 16Env & secret locations

01Stack & the core loop

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.

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.

02Launch & app entry

AllinApp.swift:11@main struct FixingApp: App

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.

AppDelegate.swift:16application(_:didFinishLaunchingWithOptions:)

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.

03Auth & identity

Core/AuthenticationManager.swift — Apple Sign In via Supabase, session restore, and identity fan-out.

:66signInWithApple()

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").

:31 / :50init() / restoreSession()

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).

:95deleteAccount()

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()).

04AppView — the orchestrator

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.

:528handleCheckInComplete(_ session:_ checkIn:)

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).

:654handleReflectionComplete(_ session:)

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).

smellAppView constructs 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.

05Check-in flow & gates

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.

The in-flow gates — handleNext() (:849)

thresholdThe express gate is >= 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).

06The router & classifiers

Core/CheckinRouter.swiftstatic func route(_ c: CheckIn, catalog:) async -> Recommendation (:165). Gate order:

  1. Crisis A (:174): keyword crisisDetected(allText)safety_handoff, no network.
  2. Crisis B + affect, concurrent (:178): async let the euphemism classifier and the affect classifier; if crisis → handoff.
  3. Intensity × valence (:184): 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).

:337AffectClassifier.classify(feeling:situation:) async -> (EmotionFamily, Valence)

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").

07Crisis detection

Three independent layers; any one triggers the support hand-off. High-recall by design.

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.

08Audio selection & playback

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.

AudioLibraryStore.swift:142 / :149sootheIntensityThreshold = 8 · resolvedLane(for:)

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).

Home/AudioSession.swift + ViewModelplayback / completion / paywall

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% finishonComplete(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.

thresholdTwo intensity thresholds disagree: router + express gate use 7; the legacy 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.

09Beliefs plan engine

iOS BeliefsPlanManager (cache) → BeliefsPlanService (I/O) → edge fn recommend-plan (composition). The whole path is deterministic Postgres FTS — no LLM.

BeliefsPlanService.swift:69generatePlan(intake:) -> (BeliefsPlan, [BeliefsPlanStep])

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.

Server: recommend-plan/index.ts

Service-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 buildFTSPlan:

bugstuck 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".

10Data layer & sessions

SupabaseService.swiftSupabaseClient.shared + emotional_sessions CRUD

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.

SessionManager.swift@Published sessions + stats

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.

11Analytics & lifecycle

iOS Segment SDK + backend edge fns → Segment "Allin iOS" source → Amplitude + Customer.io (EU) + Meta.

12Subscriptions & entitlements

SubscriptionManager.swiftRevenueCat wrapper

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.

noteTrial is 10 sessions in the live RPC (not 3, as an older comment says). revenuecat-webhook is idempotent (via revenuecat_last_event_id) but is NOT in the CI deploy — it must be deployed manually.

13Backend: migrations, RPCs, CI

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.

Live RPC bodies (from prod)

-- 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.

CI — .github/workflows/deploy.yml

Push to main, path-filtered jobs (parallel): migrations/**supabase db push; functions/** → deploy only recommend-plan (--no-verify-jwt); seed/audio_metadata.jsonnode scripts/seed.mjs (PostgREST upsert on slug). Auth via repo secrets SUPABASE_ACCESS_TOKEN/_DB_PASSWORD/_SERVICE_ROLE_KEY.

14Data model & RLS

TableHoldsRLS
emotional_sessionsemotion, body, trigger, belief, memory, desired/new belief, reframe text[], pre/post mood, reflection_notes, audio_completed/progressALL where auth.uid()=user_id
beliefs_plansintake_json jsonb, llm_reasoning (unused), is_activeown read/insert/update
beliefs_plan_stepsstep_number 1–10, audio_id, step_type (reset/rewire), pre/post mood, shift_rating, feedback_text, completedown read/insert/update
user_entitlementssubscription_status, expires_at, revenuecat_app_user_id, trial_audio_sessions_used, last_free_audio_at, consent_granted_atown read (writes via SECURITY DEFINER RPCs)
audio_libraryslug, title, filenames, duration, cluster, 3 tag arrays, search_vector (generated, GIN)public read
checkin_config · app_configchip lists + cluster_map (jsonb) · min versionpublic read
users / auth.usersid + 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.

15Known bugs & dead code

1Severity gate 7 vs 8. Express gate + router use >= 7 (EmotionalCheckIn.swift:871, CheckinRouter.swift:185); legacy AudioLibraryStore uses 8. Slider defaults to 7 → express fires by default. Intended: 8.
2stuck/stuckness seed drift (§9) — re-seeding silently breaks plan matching for 4 life areas. Fix the seed JSON.
3Dead LLM code in recommend-plan/index.ts: SYSTEM_INSTRUCTIONS, formatAudioCatalog, formatUserIntake, parseClaudePlan, ANTHROPIC_* env — all declared, never called. Live composition is 100% FTS.
4revenuecat-webhook not in CI deploy — redeploy manually. Double AuthenticationManager construction + unreachable .recommendation screen case (§4). All-or-nothing audio completion (§8). trackStartTrial() unused.
5GDPR (see the GDPR plan): analytics/Meta init before consent; consent bundled + not persisted (though record_consent_granted RPC exists, just unwired); no age gate; no UK Shout text line.

16Env & secret locations

No secret values here — locations only. Full values live in your password manager / the vaults below.

SecretWhere it lives
Supabase anon keyHardcoded in SupabaseService.swift (public by design)
Supabase service-role keySupabase project secrets + GitHub Actions secret (server only)
Supabase Personal Access TokenmacOS Keychain — service "Supabase CLI"
SEGMENT_WRITE_KEYiOS Info.plist + Supabase project secret
Facebook app id / client tokeniOS Info.plist
RevenueCat public SDK keyiOS Info.plist (RevenueCatApiKey); secret key in RC dashboard
REVENUECAT_WEBHOOK_SECRET · ANTHROPIC_API_KEYSupabase project secrets (edge fns)
ASC API key (.p8) · APNs .p8Build Mac (~/.appstoreconnect) · uploaded to Customer.io for push
Sign in with Apple keyApple Developer (key id P6GGA74424)