The Padel Society · Internal

Amity Exit — Engineering Handoff

Everything decided, built, measured and reverted on the native-social pilot — so Jeff and Kirk can take the work over without archaeology. All three branches are pushed.

2026-08-24Prepared by Paquito for NestorAudience: Jeff · Kirk

01Where the code is — three branches, all pushed

The exit spans three repos. Each has a dedicated branch on GitHub as of today. Nothing pilot-related is running in production.

RepoBranchWhat's on it
padel-backendfeat/TPS-0-native-social-pilotMigration 20260824000000_native_social_pilot.sql (773 lines: tables, RLS, triggers, feed functions) + 3 Edge Functions: sync-social-cohort, mirror-amity-social, rehost-social-media. Additive-only — no ALTER/DROP on any existing prod table.
mobile-app-padelfeat/TPS-0-native-social-pilotnative_social_repository.dart, amity_post_json_parser.dart, and the install() call in login_controller. Restored from the revert (ed527c48) onto its own branch.
Amity fork (flutter_amity_uikit_beta_service)wip/native-social-pilotThe injection seam (22c29f3): a NativeSocialOverride hook on top of tps_main that lets the app swap the feed data source without forking further.
To build the mobile branch: check the fork out on wip/native-social-pilot first — the app branch imports NativeSocialOverride from it. Day-to-day builds stay on tps_main exactly as the team rules say; the seam branch is only for pilot work.

Exact production state today (the pilot ran live on 2026-08-24 and was killed the same day)

ObjectState
Pilot enrolmentOFF social_cohort is empty and PostHog flag tps-0-native-social (id 841105, multivariate feed/feed-chat) exists but is inactive. Both kill switches were exercised for real — no deploy, no release.
11 pilot tables + 21 policiesLIVE additive-only; zero policies added to any pre-existing table except one permissive grant on realtime.messages (cohort-only, and the cohort is empty)
Mirrored dataLIVE 137 communities, 3,567 memberships, 1,707 posts (origin='mirrored', Amity untouched)
MediaLIVE 147/147 posts with media re-hosted to Storage bucket social — 304 files, 220 MB, full-size renditions, one video transcoded to 720p/faststart. Zero media still served from Amity.
3 pilot Edge FunctionsGONE — unexplained they worked, then returned 404 later the same day; never appeared in functions list. Redeploy from the branch and verify with an HTTP call after every deploy. Possible-but-unconfirmed culprit: the calculate-community-rankings redeploy at 06:51.
App codeBRANCH-ONLY never on main, never shipped to users

02Why we're leaving Amity

03The decisions, in order

Decision 1

PoC against a 10× production dataset before any production code

Isolated Supabase stack (amity-exit/poc/, own project id, own ports, additive-only guard that hard-fails on ALTER/DROP). 113k posts, 200k messages, 238k memberships. This caught real design errors before they shipped — see §04.

Decision 2

Feed is dual-write. Chat is not.

A post is a broadcast object — writing it to two stores is safe and idempotent. A message is a conversation — dual-writing it means a bidirectional bridge, echo loops, cross-clock ordering, phantom unreads and divergent deletes, all user-visible in the surface people use most. So: posts mirror to Postgres (non-fatal append, Amity stays canonical); chat gets closed native channels containing only cohort members, which never exist in Amity at all. A channel lives in exactly one store — that single rule deletes the bridge, the echo loop, the ordering problem and the phantom unreads. Full matrix: poc/PILOT-WRITE-MATRIX.md.

Decision 3

Chat rollout is a big-bang cutover, not a percentage ramp

Because chat never dual-writes, per-user ramping would strand conversations between native and Amity users. At ~1,100 socially-active users the cutover is a short low-traffic freeze window: final extract, flip everyone. The feed lane is a normal percentage ramp — dual-write makes it safe. This corrects the original plan, which assumed both lanes ramped identically.

Decision 4

PostHog is the only control plane; the DB only enforces

RLS can't call PostHog over HTTP, so social_cohort exists as a read-through cache of the flag — never a second admin surface. Kept in sync two ways: just-in-time on app launch (JWT in, server-side PostHog check — client tampering changes nothing) and a 15-minute reconcile cron so removals land without a deploy. Multivariate flag stages the lanes: feed vs feed-chat. Break-glass: delete from social_cohort kills the pilot instantly.

Decision 5

Legacy Amity IDs are permanent, and media must be re-hosted

Every deeplink ever shared carries an Amity post id → legacy_amity_id survives forever and resolve_post() accepts both id forms. Historical images 404 the day the Amity contract ends → rehost-social-media copies them into Supabase Storage and rewrites URLs during extraction.

Decision 6

The cord-cut is gated on min-version adoption, not a calendar week

Old binaries keep talking to Amity forever. app_configs already carries force_update; Phase 5 fires when adoption crosses the threshold, not on "week 10". And removing amity_sdk changes native dependencies — Phase 5 is a full store release, not a Shorebird patch. Both code paths ship in the binary before any ramp; the flip itself is server-side.

Decision 7

The existing code path is never made worse

Every Amity write in the app and backend today stays byte-for-byte as it is. Postgres writes are appended and wrapped non-fatally: if the whole native stack died, production behaviour would be unchanged for all ~17,200 users. Enforced by triggers, proven against a forced-membership attack in scripts/cohort-isolation-test.sql.

04What the PoC proved — and the bugs it caught

Warm timings on the 10× dataset. The compat gate (scripts/compat-checks.sql) is pass/fail and CI-ready: 9/9 assertions pass, including additive-only migrations, verbatim Amity ids, idempotent backfill, and Amity read-marker semantics.

BenchmarkTimeReplaces
Global feed, worst case (119 communities)2.0 msglobal-feeds + session mint + Upstash
Amity-compatible envelope (old binaries)3.1 mssame, for un-updated apps
Padel-native ranked feed9.1 msimpossible on Amity
Channel list + unread counts1.7 ms3 live collections + socket.io + an N+1
Message page, keyset over 200k rows0.57 msAmity message feed
Chat realtime end-to-end, on simulator22 msAmity socket path

Hard-won lessons encoded in the branch

05Why the app-side code was reverted this morning

Two separate things happened on 2026-08-24, and they shouldn't be conflated:

1 — Branch hygiene. The pilot app code was sitting on the perf branch and got reverted (ed527c48, 07:05): it imported NativeSocialOverride from an unpushed Amity fork working tree, so it only built on Nestor's machine, and it didn't belong in a perf PR. That blocker is now gone — the fork seam is pushed and the app code is restored on its own reviewable branch.

2 — A real, untraced crash. While the pilot was live (cohort of 3), the feed verifiably rendered from Postgres — same UI, zero Amity calls, top-3 identical to the DB. Then the app began crashing to the home screen and the pilot was killed. install() was confirmed working and the server returned correct rows; the client-side root cause was never found. A recurring Bad state: No element (a .first on an empty list — prime suspects post_item.dart:132/153) appears even in logs that predate the pilot's image work, and after switch-off the feed was empty on the Amity path too (confounders: simulator GPS override, an Amity socket reset). Do not re-enable the cohort until this is traced from a clean baseline with the pilot OFF.

06What is not proven yet

07Suggested next steps

  1. Trace the crash first. Clean baseline (pilot OFF, no simulator GPS override), reproduce Bad state: No element, bisect. Nothing re-enables before this.
  2. Review the three branches per team rules (Lead + Security approval; the migration is additive-only and RLS-heavy — worth Security's full pass). Back-fill the migration + functions into padel-backend history via PR for the team record.
  3. Redeploy the three Edge Functions from the backend branch and verify each with an HTTP call afterwards — they vanished from prod once already. PostHog secrets (POSTHOG_*, NATIVE_SOCIAL_FLAG_KEY) are already set in prod Supabase; the flag tps-0-native-social already exists (leave inactive). Wire the 15-min reconcile cron.
  4. Run the PoC locally: cd amity-exit/poc && ./scripts/run.sh — up, migrate, seed, compat gate, benchmarks, fully isolated from prod.
  5. Then the remaining Phase-1 items: extractor hardening vs the live Amity API (it 504s intermittently — retries required; media re-host is already done for the mirrored set), offline outbox spike, then re-run the cohort pilot behind the flag.