The platform_users table records membership: which users belong to which platform (tenant).
No mutation API for memberships. Rows are written in only two ways:
  1. POST /auth/token — automatically, on every login. See below.
  2. POST /platform_users/backfill — admin-only, one-shot, run once per environment after deploy.
Reads go through one endpoint of its own (admin, or a platform-scoped token pinned to it); GET /platforms and GET /users/details/basic also read the table: GET /platform_users?user_id=…&platform_id=…&statuses=… — a flat list of raw rows, newest-first by default.

The relationship is many-to-many

A user is keyed by their phone number, and the same phone can sign in through more than one partner platform. So a user holds one membership row per platform, not a single platform assignment.
Uniqueness is enforced on the pair (user_id, platform_id), never on user_id alone. A user with several memberships is normal and expected.
This is also why the tenant is not a column on users. A scalar users.platform_id would be ambiguous the moment a phone appeared on a second platform, and the second signup would silently overwrite the first.

How memberships are created

POST /auth/token is the only path a user enters the system, and the only place the platform is authoritative — it comes from the calling platform’s service-account JWT claim, never from a request body, so it cannot be spoofed.
1

A platform mints an app token

The partner backend calls POST /auth/token with its service-account bearer token and the user’s phone number.
2

The user is found or created

Existing phone → the existing user. New phone → a fresh ONBOARDING user.
3

The membership is recorded

The (user, platform) pair is upserted. Idempotent — every subsequent login through the same platform is a no-op, and a login through a different platform simply adds a second membership.

Reading memberships

GET /platform_users returns a flat, filtered page of platform_users rows. Every filter is optional and composes with AND.
All rows (paginated)
Filter by platform + status
Look up one pair

Calling with a non-admin token

The token wins over the query: a pinned filter takes the token’s value, whatever the query says. statuses, limit and offset behave exactly as for admins.
Contributor — the beneficiary's membership on the token's platform

Backfilling historical memberships

POST /platform_users/backfill is a one-shot admin endpoint. The migration ships schema only; this endpoint fills in the (user, platform) pairs that predate the table. Run it with dry_run first to see exactly what it would do, then for real:
Dry run
Response
Every candidate is read once, then each is linked on its own, so a failing candidate is reported and the run carries on. results lists only the users that were not linked: Two steps: Re-runnable. A user–platform pair that already has a membership row (even a revoked one) is never a candidate, so once everything is linked a rerun reports succeeded: 0, and a run interleaved with logins covers only the pairs login has not linked yet. A user whose ledger insert failed is not pushed onto the fallback platform; the next run retries it. created_at is copied from the source row, so it records when the membership began rather than when the backfill ran. A revoked pair is left revoked.

Revoking a membership

There is no API for this. The status column exists so an operator can revoke a membership directly in the database. Each (user, platform) pair has exactly one row, so a revoked membership is flipped back to active in place on the user’s next login through that platform.