Insights — internal
Enter the password to continue.
Insights Infrastructure

Technical source of truth · verified against live systems · 15 August 2026

Insights Infrastructure

Everything Insights runs on, read from the live systems rather than from documentation: the analytics pipeline, the databases, the iOS app, the website, the recommender, every third-party platform, and where each piece of code actually lives. Nothing here is inferred — where something could not be verified, it says so.

← Insights Wiki (priorities & tracking)

Email & analytics delivery — verified 24 August 2026

Established by reading the live consoles and querying real DNS, not from documentation. None of this was previously written down anywhere.

The path an event takes

iOS app → Segment (workspace Insights-App, source Insights) → four destinations, all enabled:

  • Insights_CustomerIO_121 events delivered in 24h, all on first try
  • Insights_Amplitude_1 — project 658118
  • Facebook Audiences — live and receiving
  • Facebook App Events — live and receiving

The Segment→Customer.io leg had never been confirmed by anyone. It works. Customer.io was never the missing link in the email problem.

Customer.io

Workspace 175574, EU region (fly.customer.io). Distinct from Allin's 216147 — they are separate workspaces under separate logins, which is why this was invisible until the right account was signed in.

From address: Insights <hello@join-insights.com>, in use.

Two verified sending domains: join-insights.com (default) and insights-app.com.

One automation exists — “Insights — Day 2 return”, last touched 18 days ago. That is the entire lifecycle programme.

How mail actually leaves — the part that matters

Customer.io does not send from the root domain. It sends from a dedicated subdomain, cioeu117492.join-insights.com, backed by Mailgun EU. Verified in DNS:

  • SPF on the subdomain: present and correctv=spf1 include:mailgun.org ~all
  • MX on the subdomain: mxa.eu.mailgun.org / mxb.eu.mailgun.org
  • DKIM at mta._domainkey.cioeu117492: MISSING — this is the defect

Customer.io's own console agrees: MX green, SPF green, DKIM red.

The one defect, and why it explains the email problem. Without DKIM, mail from this domain fails authentication. Apple’s private relay requires an SPF pass and an aligned DKIM signature, so the 146 users on @privaterelay.appleid.com cannot receive anything. Google and Yahoo have required SPF + DKIM + DMARC from bulk senders since early 2024, so even the handful with consent are likely being spam-foldered.

The fix is a single TXT record at mta._domainkey.cioeu117492, value supplied by Customer.io’s console. It sits on a subdomain that carries nothing else, so it cannot affect Google Workspace mail. DNS is on Cloudflare.

A separate, lower-urgency issue: the root domain has no SPF at all. join-insights.com publishes no v=spf1 record, while its MX is Google Workspace — so mail Marie sends herself from hello@join-insights.com is also unauthenticated.

This one does need care, and there are three senders to account for, not one:

  • Google Workspace — the MX records; Marie’s own mailboxes on the domain.
  • Squarespacejoin-insights.com is a Squarespace site (www CNAMEs to ext-sq.squarespace.com). Squarespace sends form-submission notifications, and order mail if commerce is enabled. An SPF naming only Google would silently break these.
  • Not Customer.io — that sends from the subdomain and is already covered there.

Do not conflate this with the DKIM fix above. The DKIM record is isolated and safe; this one can break live mail if it is composed carelessly.

Worth noting: join-allin.com publishes an SPF record. join-insights.com and umancstudio.com do not. All three are Squarespace sites on the same IP range, which is exactly why they get confused — check the domain, not the look of the console.

DMARC exists at p=none — monitoring only, and meaningless until SPF and DKIM pass.

Also unresolved: Customer.io reports link-tracking DNS missing (CNAME and TXT for email.join-insights.com), so tracked links in emails are not working either.

Note on insights-app.com: it has an SPF record, but it authorises IONOS — not Customer.io — and has no DKIM. If anything ever sends from that domain through Customer.io it will fail.

Analytics pipeline

Segment, Amplitude and Customer.io are all integrated and working. This section exists first because it is the piece most often described wrongly.

The pipelineVerified live

iOS → Segment → Amplitude + Customer.io. The Segment SDK (analytics-swift) is configured in InsightsApp.swift with the production write key, trackApplicationLifecycleEvents(true), flushAt(3) and flushInterval(10). Amplitude and Customer.io are cloud-mode destinations configured in the Segment dashboard — which is why there are no device-mode plugins in the app. That is the correct architecture, not a missing integration.

Proof it is working: Amplitude project 658118 contains Segment's own lifecycle event names — Application Opened, Application Backgrounded, Application Installed, Application Updated — which only exist if Segment's lifecycle tracking is flowing through to it, alongside all the custom snake_case events.

SegmentAnalytics.swift calls Analytics.shared(), which in analytics-swift returns the first Analytics instance created — i.e. the configured one. The plumbing resolves correctly.

The 13 identify traits reaching Customer.io

Every one of these was confirmed present in the iOS source, not just in the plan document.

TraitMeaningSet where
displayNameName from Apple Sign-InRootViewModel on auth
emailConsent-gated email addressRootViewModel, EmailConsentView, Settings, Onboarding
email_consentOpt-in stateEmailConsentView, Settings toggle, Onboarding
notification_consentPush permission grantedOnboarding delivery step
delivery_hourPreferred local send hourSettings
mind_themes[] · goals[]Onboarding picks — drive personalisationOnboarding
plan_name · is_subscriber · subscription_expires_atSubscription state for Paying/Free segments and the trial lifecycleRootViewModel, synced once per user
ideas_readCanonical read countAppManager
my_insights_readySelf-portrait has enough data to showAppManager
ios_device_tokenAPNs token → Customer.io device registrationInsightsApp APNs delegate

The one real gap — and it is narrowSmall build

The richest personalisation tokens are not in Segment: top_cluster_label, portrait_pct, week_ideas, week_kept, next_badge, topics_explored and journey stage. These live in the backend behind /v1/user/journey and /v2/map.

To merge "here's your dominant pattern: Relational" into a push, a handful of these need syncing to Customer.io — either a periodic identify from iOS after /journey, or a backend→Customer.io API call. This is an enhancement to a working pipeline, not a missing integration. It is the single engineering prerequisite for truly personalised campaign copy (CRM plan §4).

Why the backend has no Segment call

The FastAPI service contains no analytics.identify anywhere, and that is by design — the pipeline is client-side. The consequence worth naming: POST /v1/user/consent writes consent and push token to Supabase only, so user_profiles is a record, not a sync. Customer.io learns about consent because iOS fires identify at the same moment. If that call is dropped, Supabase and Customer.io can drift with nothing to reconcile them.

Push reachability is the live problem16%

38 of 239 users have a push token. On the shipped build the APNs token is attached once, during onboarding's delivery step; on later launches the delegate never re-fires, so the token is never re-attached to Segment and Customer.io's Create/Update Device event has no device_id. registerForPushIfAuthorized fixes exactly this and exists on the work branch — 0 occurrences on paths-v1. Blocked on shipping a build, not on code.

Same shape for email: 8 users have email_consent = true, 129 are NULL, because the Settings toggle "Email me new insights" is also absent from the live build — so anyone who skipped consent in onboarding has no recovery path.

System at a glance

iOS app SwiftUI · Swinject 202 files · 25k lines Segment SDK 13 identify traits Amplitude 658118 Customer.io FastAPI Railway · 10 routers 35 endpoints + AEO Postgres Supabase Ireland 26 tables · pgvector OpenAI · Anthropic embeddings · Haiku all data auth cloud-mode read + write
The client authenticates against Supabase but never reads application data directly — every row goes through FastAPI on the service role. Analytics is a separate client-side pipe straight to Segment.
LayerTechnologyScale today
ClientSwiftUI, iOS only202 Swift files, ~25,400 lines
API + websiteFastAPI 0.110 · uvicorn · Python 3.1110 routers, 35 endpoints, 518-line AEO layer
DatabasePostgres via Supabase, eu-west-126 tables, ~18,600 rows
Vectorspgvector 0.8.0, 1536-dim, HNSW + IVFFlat697 cards embedded
AnalyticsSegment → Amplitude + Customer.io13 traits, ~50 event types
HostingRailway, Docker python:3.11-slimOne service, auto-deploy on main
Marketing siteSquarespacejoin-insights.com
EdgeCloudflares.join-insights.com

How a card reaches a user

/v2/daily is a dumb read, by design

It does not assemble a deck at request time. Every decision writes into prepared_cards, and the endpoint returns what is already there — the morning open is a single indexed read, not a recommender run.

  1. Onboarding picks still unserved — 2 picks plus 1 diversifier. Cold-start bootstrap.
  2. Agent-prepared cards — whatever the 3am job wrote.
  3. Algorithmic fallback — live selection if neither has anything.

Same-day idempotency: once 3+ cards are served today, every later call that day returns those cards. Without it, a kill-and-reopen re-picked fresh cards and the user's day changed under them.

iOS application

Structure

  • Components/Screens/ — 139 files, ~20,700 lines. Four areas: Home (swipe deck), Cards (reader, details, bookmarks), Root, Shared.
  • Components/Services/ — 39 files, split API/ (protocol) and Impl/ (concrete), so screens depend on protocols and previews and tests substitute freely. Analytics, Auth and Logging all follow this shape.
  • Components/DI/ — 14 files. Swinject assemblies with separate production and preview registries.

Dependencies

RevenueCat (subscriptions + paywall UI) · Supabase Swift (auth, PostgREST) · Segment analytics-swift · Alamofire · Lottie 4.2.0 · Facebook SDK (app 2399062313945513) · Google Sign-In.

Build boundary

Live branch is paths-v1, not main. The App Store version number is decoupled from the git MARKETING_VERSION. Xcode builds are run by the owner; automation writes code but never builds or uploads.

Backend

RouterPrefixOwns
cardsDaily deck, interactions, onboarding selection, popular pool
recommend/v2/recommend — the agentic Lens Shift recommender
self_modelMap, clusters, mirror sentence
user/v1/userJourney, consent, onboarding paths, GDPR export and deletion
pathsPath catalogue, enrolment, progress
notesNotes and reminders
deeperGo-deeper — a Claude conversation seeded by a card, over SSE
favoritesSaves
referral/v1/referralGift-a-friend Apple Offer Codes
admin/adminManual triggers for the nightly jobs
seo_routerThe entire public website — see Website & AEO

Two helpers everything must route through

one_or_none(query) — the deployed postgrest-py raises on zero rows for both .single() and .maybe_single(), which turned every "row doesn't exist yet" read into a 500. Use this instead, always.

fetch_all(build_query) — PostgREST caps any un-ranged select at 1,000 rows and reports no error, so a plain .execute() on a grown table silently returns a partial result and every count from it is wrong. Pass an already-ordered builder.

API surface

35 endpoints. Authenticated routes take a Supabase JWT as Authorization: Bearer.

MethodPathPurpose
GET/healthLiveness
POST/v2/daily · /free · /interact · /resetThe deck and its interactions
POST/v2/recommendAgentic recommendation from a check-in string
POST/v2/signalSwipe / resonance signal
GET/v2/map · /v2/map/cluster/{label}The living map
GET / POST/v2/self-model · /recomputeIdeas count, weights, mirror
GET / POST/v2/onboarding/cards · /selectOnboarding card set and picks
GET/v1/user/journeyStage, stats, streak
POST/v1/user/consentPush token + email/notification consent
GET / DELETE/v1/user/dataGDPR export and account deletion
GET / POST/v1/paths · /v1/user/paths · /{slug}/startPaths and enrolment
GET / POST / DELETE/v1/favorite · /v1/notesSaves, notes and reminders
POST/v1/deeperGo-deeper Claude chat (SSE)
POST/v1/referral/claimDispense an Apple Offer Code
GET/public/card/{id}Unauthenticated teaser for web / AEO
POST/admin/prepare-daily · /recompute-self-modelsManual job triggers

Data model

26 tables, row counts live as of 15 August 2026.

TableRowsShape
stories5,053card_id · title · content · is_quote · order_index — the reader's slides
card_interactions4,323user_id · card_id · type · interacted_at · metadata jsonb
key_insights3,282Extracted takeaways per card
served_cards1,418Unique on (user, card)
sources · quests701Attribution, and the reflection prompt per card
cards697title · author_id · topic · subtopic · hook · ranking · embedding vector(1536)
authors511name · tagline · description · profile_image_url
referral_codes500Pool is loaded — code · claimed_by · redeemed_at
user_path_progress307PK (user, path) · current_position · state
prepared_cards179card_ids jsonb · reasoning jsonb · journey_stage · consumed_at
user_profiles137intent_embedding vector(1536) · journey_state · topics_engaged · email_consent · notification_consent · push_token
user_self_model131ideas · weights real[] · mirror
user_clusters109label · centroid vector(1536) · weight · card_count
favorites92user_id · card_id · favorited_at
user_signals34check_in_text · input_type · selected_card_ids[] · input_embedding
paths15slug · title · path_type · duration_estimate · difficulty_arc

Modelling gap

user_profiles.id is a foreign key to auth.users(id) with ON DELETE CASCADE, but there is no trigger creating the row on signup. Rows are created lazily by /v2/daily, /v2/recommend and the consent write. Today 103 of 239 users have no profile row, so anything iterating user_profiles — including the nightly agent's user list — is blind to them.

Recommender & vectors

Split deliberately: orchestration in Python, vector maths in Postgres.

WhereWhatSize
Python services/recommender.pyIntent embeddings, signal weighting, candidate selection, path advancement1,004 lines
Python services/recommendation_agent.pyThe overnight Claude Haiku agent that writes prepared_cards393
Python services/self_model.pyClustering and map generation276
Python services/suppression.pyAnti-repetition rules56
Python services/embeddings.py · pinecone_client.pyEmbedding generation. Pinecone is a declared dependency but pgvector serves retrieval62
Postgres match_cards()pgvector cosine similarity searchSQL function
Postgres fuzzy_recall_card_ids()Trigram recall over story contentSQL function
Postgres get_daily_cards() · claim_referral_code()Deck read; atomic offer-code dispenseSQL functions

Indexes

  • cards_embedding_hnsw_idx — HNSW, cosine. The one serving retrieval.
  • cards_embedding_idx — IVFFlat lists=100. Older; redundant alongside HNSW and worth dropping once confirmed unused.
  • stories_content_trgm — GIN trigram, backs fuzzy recall.
  • Hot paths indexed: card_interactions (user_id, interacted_at DESC) and (user_id, type); served_cards (user_id, served_date); partial index on unconsumed prepared_cards; partial index on active paths.

Lens Shift

/v2/recommend takes a free-text check-in and returns three cards on different axes: Mirror (matches what's on their mind), Reframe (same truth, different discipline), Stretch (tangential, barely-explored topic). Wrapped in a crisis fallback that degrades to random unseen cards rather than erroring.

Website & AEO

There is no separate web repository. The public site is server-rendered by the same FastAPI service.

seo_aeo_routes.py — 518 linesLive, 200

Canonical host https://s.join-insights.com, styled to match join-insights.com exactly — black ground, layered warm-orange glow, Cormorant serif.

RoutePurposeLive
/card/{card_id}Teaser page per insight — indexed, full OG + meta description200
/journey/{slug} · /path/{slug}Journey and path pages200
/author/{author_id}Author pages200
/ · /questionsIndex and the AEO question hub200
/sitemap.xml · /robots.txt · /llms.txtCrawler surface, including an LLM-specific file200

Gating holds: card pages emit title, description and structured data for indexing but never the full content — the library stays in the app.

Domains & DNS

Checked live, not from documentation.

DomainServesBehindStatus
join-insights.comMarketing site, terms, privacySquarespace200
s.join-insights.comAEO site, deep-link target, AASA, share URLsCloudflare → Railway200
share.join-insights.comNothing — referenced only in a stale code commentCloudflare404
go.join-insights.comNothing — old backlog item, never completeddoes not resolve

Deep links are live and correct

AASA is served at s.join-insights.com/.well-known/apple-app-site-association for appID 77A25Z388N.com.umancstudio.insights, covering /card/*, /insight/*, /home, /profile, /journey/*, /path/* and /refer.

Share URLs are built in CardsConstants.swift as https://s.join-insights.com/card/{id} — the working host. The two dead subdomains above are cosmetic references only and break nothing.

Scheduled work

APScheduler inside the API process. Two nightly jobs, UTC.

TimeJobWhat it does
03:00nightly_prepareClaude Haiku picks tomorrow's cards per user, writes prepared_cards with reasoning and a journey stage
03:20nightly_self_modelsRecomputes clusters, weights, node links and the mirror sentence — after card prep, by design

Every platform

PlatformRoleIdentifier
SupabasePostgres, auth, storagebkiodocaqdgtkaixoisn · eu-west-1 · org iioyxtgmhiosoqlxfdcp
Supabase (second)atlas-us, us-east-1 — created 15 Jun, purpose unconfirmedwyclsyaxfhowlmarbzxr
RailwayAPI + website hosting, auto-deployatlas-api-production-8c22.up.railway.app
CloudflareSSL / proxy for s.join-insights.comWorker insights-proxy
SquarespaceMarketing sitejoin-insights.com
SegmentAnalytics bus — the single source feeding both destinationsWrite key in InsightsApp.swift
AmplitudeProduct analytics, cloud-mode destinationProject 658118 · org join-insights-466344
Customer.ioLifecycle email + push, cloud-mode destinationReceives 13 identify traits
OpenAIEmbeddings, 1536-dimopenai==1.14.0
AnthropicHaiku — nightly prep, cluster naming, go-deeper chatanthropic>=0.42.0
RevenueCatSubscriptions, entitlements, paywall UIPublic key appl_ABNW…
AppleApp Store, StoreKit, Offer Codes, APNsApp 6761016582 · team 77A25Z388N · bundle com.umancstudio.insights
MetaPaid acquisition + SDK attributionAd account 1131409802138851 · FB app 2399062313945513
PineconeDeclared dependency, not on the serving pathpgvector serves retrieval

Security & access

RLS posture

All 26 public tables have RLS enabled. 15 carry explicit policies; the other 11 — including prepared_cards, referral_codes, user_signals, paths, user_path_progress — have RLS on with no policy, i.e. deny-all to anon and authenticated.

That is deliberate rather than accidental: the client never reads application data directly. Every row is fetched by FastAPI on the service role, which bypasses RLS. The consequence to state plainly is that the backend is the only enforcement point — there is no defence in depth if an endpoint forgets its user scoping.

Authentication

Supabase Auth, JWT bearer tokens verified server-side on every request via get_current_user. get_current_user_optional serves endpoints that must work for both anonymous and signed-in callers.

Extensions

vector 0.8.0 · pg_trgm 1.6 · pgcrypto 1.3 · uuid-ossp 1.1 · supabase_vault 0.3.1 · pg_stat_statements 1.11

Privacy

GDPR export and deletion are real endpoints, not manual processes. Consent is captured at onboarding and stored with a timestamp. Apple provides only email and name at sign-in, so the database holds no geographic data — territory questions must be answered from Amplitude or RevenueCat.

Credential policy

No secret, token, API key or database password appears in this document, in repository files, or in agent memory — by standing policy. Credentials live in Railway environment variables, Supabase project settings and the Apple Developer account, and are requested at the point of need.

Repos on disk

There are seventeen Insights checkouts on this machine. Only two are live. This is the most likely way to work on the wrong code.

PathBranchLast commitStatus
~/Documents/insights-ios-2026audit-fixes-2026-08-072026-08-15LIVE iOS
~/Documents/insights-backend-2026main2026-08-15LIVE backend
~/Documents/insights-hq · Insights-Docsmain2026-08-13Live docs
~/Documents/atlas-apimain2026-03-17Stale — but holds the working .env
~/Documents/insights-backend · atlas-backend · atlas-insightsmainMar 2026 / Oct 2025Stale
~/Desktop/insights-live-2026-05-05/*paths-v1 / main2026-05/06Stale snapshot
~/Desktop/insights-source-2026-05-05/* · Insights-backup-2026-04-30/*variousApr–May 2026Stale snapshots
~/Desktop/Insightsf-analytics2024-10-23Two years old

Environments & deploy

  • Backend: push to main auto-deploys to Railway. There is no staging — main is production. Health check GET /health.
  • iOS: live branch paths-v1. Archive and upload run headless via the App Store Connect API. Never pipe xcodebuild through tail — it masks the exit code.
  • Database: Ireland is the source of truth. A US read-mirror is planned; the atlas-us project exists but its role is unconfirmed.
  • Local API config: the iOS project carries INSIGHTS_API_ENDPOINT_LOCAL (127.0.0.1:4000) and _PROD, selected per build configuration.

Risks & gaps

Each of these has either happened or is measurable today.

Silent-failure classSystemic

The costliest bugs are writes and reads that fail quietly while the product looks fine. Three found in one sweep: intent-embedding writes using .update() on rows that don't exist; a popular-cards read hitting the silent 1,000-row cap; analytics emits stranded in a view model no view imports. All three left the UI working perfectly while the data went nowhere. one_or_none and fetch_all now exist to prevent the first two classes.

Migrations are not a record of productionProcess

A migration in the repo that was never applied is a 500 for every user, and this has happened. Check the live schema; the migrations folder is not the source of truth.

Operational concentration

  • One Anthropic key behind cluster naming, go-deeper chat and the 3am job — exhaustion fails all three at once and looks like three unrelated bugs.
  • The nightly scheduler runs in-process with the web service, so a restart in the window can skip a night silently.
  • insights-proxy is a single Cloudflare Worker behind every share link, deep link and the AASA file.
  • No staging environment.
  • The backend is the only place user scoping is enforced.
  • Seventeen repo checkouts on one machine, two of them live.

Documentation discrepancy worth resolving

The AEO site and the font brief both specify Cormorant Garamond + Quicksand as the brand typefaces, matching join-insights.com. The design-rules memory says Didot headers. These contradict; the site and the brief agree with each other, so Didot is the likely error.

Your feedback on this page