Allin — internal
Enter the password to continue.
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.swift — static 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").

correction — NOT actually liveA fix was written 2026-08-14 (validating the model's tool-call output against the schema's real family/valence sets before returning, so an out-of-schema value throws instead of silently corrupting) — found via a live 135-run empirical test that an out-of-schema value broke client-side decoding (a calm "quiet Sunday" check-in got misread as anxious and routed to a sleep-anxiety audio). A 2026-08-16 scale-readiness audit downloaded the actual live function and diffed it byte-for-byte against the repo: live is still version 4 (deployed 2026-06-17), with no validation guard at all — 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.

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

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

correctionThis section previously said the whole path was "deterministic Postgres FTS — no LLM." That was wrong, caught 2026-08-16 during a scale-readiness audit. The client (line below) has always sent 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.
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, 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:

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

was liveRepo/live drift, found + fixed 2026-08-14. The real, correct 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.

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

1 — fixedSeverity gate 7 vs 8. Express gate + router used >= 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.
2stuck/stuckness seed drift (§9) — re-seeding silently breaks plan matching for 4 life areas. Still open — fix the seed JSON.
3 — corrected 2026-08-16Not dead code — this was wrong. 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.
4revenuecat-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.
5GDPR (see the GDPR & tone-of-voice wikis): analytics/Meta init before consent — fixed on the same branch, plus consent unbundled into 3 granular toggles; consent record persistence still unwired (record_consent_granted RPC exists, unused); no age gate (still open); UK Shout 85258 text line added (fixed, same branch, not live).
6Live empirical audio-matching test (135 synthetic check-ins through the real pipeline, 2026-08-14): crisis safety-net caught 3/3 real cases with zero false alarms; 55 distinct audios used across 132 picks. Two real problems found: (a) the classify-affect model disagreed with the picker's own downstream read of the same input 22% of the time (26/119) — separate from the (still NOT live, see §6's correction above) schema-validation bug, this is the model itself, not a decode failure, and stays roughly flat regardless of user count — it's a calibration mismatch, not a volume effect; (b) no soothe-lane audio exists for the "shame" emotion family at all — shame check-ins default to a generic panic/stress audio (the single most-used audio, 11/132 picks), including one clear miss where body-image shame routed to a chronic-illness audio. At 5000 users the ~8.3% synthetic rate extrapolates to roughly 400+ people hitting this gap, recurring every time they have a shame-flavored high-intensity check-in — needs new audio content, not a code fix. Neither the 22% disagreement rate nor the shame gap is fixed — the first needs model/prompt work, the second needs Isabel/Marie's content judgment on what a shame-soothe audio should even say, not an engineering guess.

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)
Your notes on this page
Saves as you type. Copy or save, then paste back to Claude.