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.
The exit spans three repos. Each has a dedicated branch on GitHub as of today. Nothing pilot-related is running in production.
| Repo | Branch | What's on it |
|---|---|---|
padel-backend | feat/TPS-0-native-social-pilot | Migration 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-padel | feat/TPS-0-native-social-pilot | native_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-pilot | The injection seam (22c29f3): a NativeSocialOverride hook on top of tps_main that lets the app swap the feed data source without forking further. |
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.
| Object | State |
|---|---|
| Pilot enrolment | OFF 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 policies | LIVE 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 data | LIVE 137 communities, 3,567 memberships, 1,707 posts (origin='mirrored', Amity untouched) |
| Media | LIVE 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 Functions | GONE — 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 code | BRANCH-ONLY never on main, never shipped to users |
entity_posts, joined_action_posts) round-tripped through Amity.tps_main) just to style and extend their UI. Every upstream change is a merge war.amity-exit/index.html in the repo folder.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.
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.
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.
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.
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.
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.
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.
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.
| Benchmark | Time | Replaces |
|---|---|---|
| Global feed, worst case (119 communities) | 2.0 ms | global-feeds + session mint + Upstash |
| Amity-compatible envelope (old binaries) | 3.1 ms | same, for un-updated apps |
| Padel-native ranked feed | 9.1 ms | impossible on Amity |
| Channel list + unread counts | 1.7 ms | 3 live collections + socket.io + an N+1 |
| Message page, keyset over 200k rows | 0.57 ms | Amity message feed |
| Chat realtime end-to-end, on simulator | 22 ms | Amity socket path |
VACUUM ANALYZE after backfill is load-bearing.resolve_post() needed CASE, not AND — SQL doesn't short-circuit, so every legacy deeplink would have 500'd.SECURITY DEFINER helpers — self-referencing policies recurse (42P17), and RLS without GRANTs is authorisation theatre (42501).realtime.send()'s 4th arg is private (default true), not public. Server published public, client subscribed private, both reported success, zero messages delivered. Every SQL test passed while chat was completely broken — which is the whole argument for testing with a real client on the simulator.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.
Bad state: No element crash — untraced, blocks re-enabling the pilot. Reproduce with the pilot OFF and no GPS override first, then bisect.Bad state: No element, bisect. Nothing re-enables before this.padel-backend history via PR for the team record.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.cd amity-exit/poc && ./scripts/run.sh — up, migrate, seed, compat gate, benchmarks, fully isolated from prod.