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_1— 21 events delivered in 24h, all on first tryInsights_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 correct —
v=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.
- Squarespace —
join-insights.comis a Squarespace site (wwwCNAMEs toext-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.
| Trait | Meaning | Set where |
|---|---|---|
displayName | Name from Apple Sign-In | RootViewModel on auth |
email | Consent-gated email address | RootViewModel, EmailConsentView, Settings, Onboarding |
email_consent | Opt-in state | EmailConsentView, Settings toggle, Onboarding |
notification_consent | Push permission granted | Onboarding delivery step |
delivery_hour | Preferred local send hour | Settings |
mind_themes[] · goals[] | Onboarding picks — drive personalisation | Onboarding |
plan_name · is_subscriber · subscription_expires_at | Subscription state for Paying/Free segments and the trial lifecycle | RootViewModel, synced once per user |
ideas_read | Canonical read count | AppManager |
my_insights_ready | Self-portrait has enough data to show | AppManager |
ios_device_token | APNs token → Customer.io device registration | InsightsApp 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
| Layer | Technology | Scale today |
|---|---|---|
| Client | SwiftUI, iOS only | 202 Swift files, ~25,400 lines |
| API + website | FastAPI 0.110 · uvicorn · Python 3.11 | 10 routers, 35 endpoints, 518-line AEO layer |
| Database | Postgres via Supabase, eu-west-1 | 26 tables, ~18,600 rows |
| Vectors | pgvector 0.8.0, 1536-dim, HNSW + IVFFlat | 697 cards embedded |
| Analytics | Segment → Amplitude + Customer.io | 13 traits, ~50 event types |
| Hosting | Railway, Docker python:3.11-slim | One service, auto-deploy on main |
| Marketing site | Squarespace | join-insights.com |
| Edge | Cloudflare | s.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.
- Onboarding picks still unserved — 2 picks plus 1 diversifier. Cold-start bootstrap.
- Agent-prepared cards — whatever the 3am job wrote.
- 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, splitAPI/(protocol) andImpl/(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
| Router | Prefix | Owns |
|---|---|---|
cards | — | Daily deck, interactions, onboarding selection, popular pool |
recommend | — | /v2/recommend — the agentic Lens Shift recommender |
self_model | — | Map, clusters, mirror sentence |
user | /v1/user | Journey, consent, onboarding paths, GDPR export and deletion |
paths | — | Path catalogue, enrolment, progress |
notes | — | Notes and reminders |
deeper | — | Go-deeper — a Claude conversation seeded by a card, over SSE |
favorites | — | Saves |
referral | /v1/referral | Gift-a-friend Apple Offer Codes |
admin | /admin | Manual triggers for the nightly jobs |
seo_router | — | The 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness |
| POST | /v2/daily · /free · /interact · /reset | The deck and its interactions |
| POST | /v2/recommend | Agentic recommendation from a check-in string |
| POST | /v2/signal | Swipe / resonance signal |
| GET | /v2/map · /v2/map/cluster/{label} | The living map |
| GET / POST | /v2/self-model · /recompute | Ideas count, weights, mirror |
| GET / POST | /v2/onboarding/cards · /select | Onboarding card set and picks |
| GET | /v1/user/journey | Stage, stats, streak |
| POST | /v1/user/consent | Push token + email/notification consent |
| GET / DELETE | /v1/user/data | GDPR export and account deletion |
| GET / POST | /v1/paths · /v1/user/paths · /{slug}/start | Paths and enrolment |
| GET / POST / DELETE | /v1/favorite · /v1/notes | Saves, notes and reminders |
| POST | /v1/deeper | Go-deeper Claude chat (SSE) |
| POST | /v1/referral/claim | Dispense an Apple Offer Code |
| GET | /public/card/{id} | Unauthenticated teaser for web / AEO |
| POST | /admin/prepare-daily · /recompute-self-models | Manual job triggers |
Data model
26 tables, row counts live as of 15 August 2026.
| Table | Rows | Shape |
|---|---|---|
stories | 5,053 | card_id · title · content · is_quote · order_index — the reader's slides |
card_interactions | 4,323 | user_id · card_id · type · interacted_at · metadata jsonb |
key_insights | 3,282 | Extracted takeaways per card |
served_cards | 1,418 | Unique on (user, card) |
sources · quests | 701 | Attribution, and the reflection prompt per card |
cards | 697 | title · author_id · topic · subtopic · hook · ranking · embedding vector(1536) |
authors | 511 | name · tagline · description · profile_image_url |
referral_codes | 500 | Pool is loaded — code · claimed_by · redeemed_at |
user_path_progress | 307 | PK (user, path) · current_position · state |
prepared_cards | 179 | card_ids jsonb · reasoning jsonb · journey_stage · consumed_at |
user_profiles | 137 | intent_embedding vector(1536) · journey_state · topics_engaged · email_consent · notification_consent · push_token |
user_self_model | 131 | ideas · weights real[] · mirror |
user_clusters | 109 | label · centroid vector(1536) · weight · card_count |
favorites | 92 | user_id · card_id · favorited_at |
user_signals | 34 | check_in_text · input_type · selected_card_ids[] · input_embedding |
paths | 15 | slug · 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.
| Where | What | Size |
|---|---|---|
Python services/recommender.py | Intent embeddings, signal weighting, candidate selection, path advancement | 1,004 lines |
Python services/recommendation_agent.py | The overnight Claude Haiku agent that writes prepared_cards | 393 |
Python services/self_model.py | Clustering and map generation | 276 |
Python services/suppression.py | Anti-repetition rules | 56 |
Python services/embeddings.py · pinecone_client.py | Embedding generation. Pinecone is a declared dependency but pgvector serves retrieval | 62 |
Postgres match_cards() | pgvector cosine similarity search | SQL function |
Postgres fuzzy_recall_card_ids() | Trigram recall over story content | SQL function |
Postgres get_daily_cards() · claim_referral_code() | Deck read; atomic offer-code dispense | SQL functions |
Indexes
cards_embedding_hnsw_idx— HNSW, cosine. The one serving retrieval.cards_embedding_idx— IVFFlatlists=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 unconsumedprepared_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.
| Route | Purpose | Live |
|---|---|---|
/card/{card_id} | Teaser page per insight — indexed, full OG + meta description | 200 |
/journey/{slug} · /path/{slug} | Journey and path pages | 200 |
/author/{author_id} | Author pages | 200 |
/ · /questions | Index and the AEO question hub | 200 |
/sitemap.xml · /robots.txt · /llms.txt | Crawler surface, including an LLM-specific file | 200 |
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.
| Domain | Serves | Behind | Status |
|---|---|---|---|
join-insights.com | Marketing site, terms, privacy | Squarespace | 200 |
s.join-insights.com | AEO site, deep-link target, AASA, share URLs | Cloudflare → Railway | 200 |
share.join-insights.com | Nothing — referenced only in a stale code comment | Cloudflare | 404 |
go.join-insights.com | Nothing — old backlog item, never completed | — | does 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.
| Time | Job | What it does |
|---|---|---|
03:00 | nightly_prepare | Claude Haiku picks tomorrow's cards per user, writes prepared_cards with reasoning and a journey stage |
03:20 | nightly_self_models | Recomputes clusters, weights, node links and the mirror sentence — after card prep, by design |
Every platform
| Platform | Role | Identifier |
|---|---|---|
| Supabase | Postgres, auth, storage | bkiodocaqdgtkaixoisn · eu-west-1 · org iioyxtgmhiosoqlxfdcp |
| Supabase (second) | atlas-us, us-east-1 — created 15 Jun, purpose unconfirmed | wyclsyaxfhowlmarbzxr |
| Railway | API + website hosting, auto-deploy | atlas-api-production-8c22.up.railway.app |
| Cloudflare | SSL / proxy for s.join-insights.com | Worker insights-proxy |
| Squarespace | Marketing site | join-insights.com |
| Segment | Analytics bus — the single source feeding both destinations | Write key in InsightsApp.swift |
| Amplitude | Product analytics, cloud-mode destination | Project 658118 · org join-insights-466344 |
| Customer.io | Lifecycle email + push, cloud-mode destination | Receives 13 identify traits |
| OpenAI | Embeddings, 1536-dim | openai==1.14.0 |
| Anthropic | Haiku — nightly prep, cluster naming, go-deeper chat | anthropic>=0.42.0 |
| RevenueCat | Subscriptions, entitlements, paywall UI | Public key appl_ABNW… |
| Apple | App Store, StoreKit, Offer Codes, APNs | App 6761016582 · team 77A25Z388N · bundle com.umancstudio.insights |
| Meta | Paid acquisition + SDK attribution | Ad account 1131409802138851 · FB app 2399062313945513 |
| Pinecone | Declared dependency, not on the serving path | pgvector 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.
| Path | Branch | Last commit | Status |
|---|---|---|---|
~/Documents/insights-ios-2026 | audit-fixes-2026-08-07 | 2026-08-15 | LIVE iOS |
~/Documents/insights-backend-2026 | main | 2026-08-15 | LIVE backend |
~/Documents/insights-hq · Insights-Docs | main | 2026-08-13 | Live docs |
~/Documents/atlas-api | main | 2026-03-17 | Stale — but holds the working .env |
~/Documents/insights-backend · atlas-backend · atlas-insights | main | Mar 2026 / Oct 2025 | Stale |
~/Desktop/insights-live-2026-05-05/* | paths-v1 / main | 2026-05/06 | Stale snapshot |
~/Desktop/insights-source-2026-05-05/* · Insights-backup-2026-04-30/* | various | Apr–May 2026 | Stale snapshots |
~/Desktop/Insights | f-analytics | 2024-10-23 | Two years old |
Environments & deploy
- Backend: push to
mainauto-deploys to Railway. There is no staging —mainis production. Health checkGET /health. - iOS: live branch
paths-v1. Archive and upload run headless via the App Store Connect API. Never pipexcodebuildthroughtail— it masks the exit code. - Database: Ireland is the source of truth. A US read-mirror is planned; the
atlas-usproject 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-proxyis 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.