Coalitions (Groundwork)
Two businesses can recognize the members they share. Nothing moves points between them yet.
A coalition links two or more tenants so each side can see, in aggregate, how many of its own members also show up at its partners — without either side ever learning who, or which partner. Each business’s ledger stays exactly as sovereign as it always was: there is no code path anywhere that writes a point onto one tenant’s balance because of something that happened at another. That’s the “groundwork” in the name — the recognition layer ships in full; a real point-transfer layer is designed and tested against the real ledger schema, and switched off. See What deliberately doesn’t ship below.
Coalitions and the invite handshake
A coalition (coalitions, migration 0031) is a named group of tenants. Membership
(coalition_members) tracks each tenant’s role (owner or member) and status (invited,
active, or left) inside it.
| Action | Endpoint | Who | Effect |
|---|---|---|---|
| Create | POST /v1/coalitions {name} |
Any tenant | The creating tenant becomes the coalition’s owner, status: active, in the same transaction as the coalition itself — there is never a moment a coalition exists with no members. v1 has no platform-level approval gate on who may start one. |
| Invite | POST /v1/coalitions/:id/invites {org_slug} |
The owner only | Resolves org_slug — the same globally-unique slug your organization already uses elsewhere on this platform — to a tenant, and creates that tenant’s membership row as invited. Unknown slug → 404. A target that already holds any row for this coalition (invited, active, or left) → 409 COALITION_ALREADY_MEMBER; v1 has no re-invite path for a tenant that has left. |
| Accept | POST /v1/coalitions/:id/accept |
The invited tenant, its own session | Moves that tenant’s own row invited → active. No current invite → 409 INVALID_STATE. |
| Decline | POST /v1/coalitions/:id/decline |
The invited tenant, its own session | Moves that tenant’s own row invited → left. Same 409 INVALID_STATE if there’s no current invite. |
| Leave | POST /v1/coalitions/:id/leave |
An active member | Moves that tenant’s own row to left. Idempotent — calling it again is a no-op. A still-invited tenant must decline instead — calling leave gets the same honest 404 a non-member gets (see Errors). |
| Dissolve | POST /v1/coalitions/:id/dissolve |
The owner only | Flips the coalition’s own status to dissolved. coalition_members rows are left completely untouched — the full history stays queryable. Idempotent — dissolving twice is a no-op. |
The owner can’t leave out from under an active coalition. As long as another active member
remains, the owner’s own leave call fails with 409 COALITION_OWNER_CANNOT_LEAVE — dissolve
instead, which is an explicit, named action rather than an accidental side effect of one tenant
walking away. An owner with no other active members left is free to leave; the coalition then
simply has no owner (nothing in this version re-homes ownership — a narrow, accepted edge case).
Read your own coalitions with GET /v1/coalitions — every coalition you currently hold an
invited or active row in, newest first, with your own role/status and the coalition’s
active member_count (invited-but-unaccepted tenants don’t count toward it yet). Read the full
picture of one coalition — every partner tenant’s org name, role, and status — with
GET /v1/coalitions/:id. That detail read is visible to an invited tenant too, on purpose: you
can see who you’d be joining before you decide whether to accept.
The nightly overlap computation
Coalitions don’t compare members in real time. Once a night, a sweep (30 1 * * * UTC — the sixth
isolated leg riding that slot, alongside nightly RFM scoring, wallet-offer expiry, the challenge
sweep, the optimizer leg, and the paid-membership lapse sweep; still 13 cron triggers total, not
14) walks every coalition with at least two active members and recomputes which members are
shared: for each phone number that turns up in more than one of those tenants’ own member records,
it records the (hashed) link. Every run fully deletes and reinserts a coalition’s links rather than
patching them incrementally — simple and idempotent, at the cost of not being instant.
That means every number in this feature is honestly stale by up to one night. Invite a
partner, accept, and seed some overlapping members, and neither the overlap report nor the
linked_elsewhere flag described below reflects it until the next 01:30 UTC run. The same lag
applies in reverse: if a coalition dissolves or drops below two active members, its links aren’t
cleared until the very next nightly run notices it’s no longer eligible and purges them — stale
numbers don’t linger forever, but they also don’t clear the instant someone leaves. Nothing in this
feature recomputes on demand.
v1 links on phone number only — the same identifier hash the rest of the platform already uses for phone identifiers. Email-based linking is a natural future extension, not built yet.
The overlap report
GET /v1/coalitions/:id/overlap answers one question for the calling tenant: how many of my own
members also showed up, as of last night’s run, in one of my partners’ member lists? The caller
must be an active member — stricter than the invited-or-active visibility every other coalition
read uses — an honest 404 otherwise, same as any other non-member.
{
"linked_members": 42,
"total_members": 900,
"by_partner_count": { "2": 39, "3": 3 }
}
linked_members— how many of your members were linked to at least one other coalition tenant.total_members— your own total member count, for context.by_partner_count— of those linked members, how many were shared with exactly one partner (key"2"— you plus one other tenant), how many with two partners (key"3"), and so on. Keys are the total distinct-tenant size of the link, not a partner count offset by one.
That is the entire shape. There is no endpoint, response field, or export anywhere that names which partner tenant a member is shared with, or returns any of that partner’s own member data — see The privacy model below for why that’s a hard boundary, not an oversight.
The linked_elsewhere member flag
Every member response — GET /v1/members and GET /v1/members/:id — carries one additional
field, always present:
"linked_elsewhere": true
false for every member outside a coalition, which — before you join one — is every member you
have. It flips true the morning after the sweep finds one of that member’s own phone hashes in a
coalition partner’s own member list too. Like the overlap report, it never says which coalition or
which partner: it’s a plain yes/no signal about your own member, computed from the same nightly
links. The dashboard surfaces it as a badge on the members list and member detail; it’s also a
plain wire field any client can build on (see v1 limits).
What deliberately doesn’t ship: live point transfers
Nothing in this feature moves a point from one tenant’s ledger to another’s. Each business’s
ledger stays exactly as sovereign as it always was — ledger_entries still requires its own
per-tenant append-only history, and there is no route, cron, or service anywhere that writes a
cross-tenant transfer.
The schema for that future product already exists — coalition_transfers (migration 0031): a
proposed/committed/reversed transfer between a from_tenant_id and a to_tenant_id, with
points > 0, and two columns (debit_ledger_ref/credit_ledger_ref) reserved for the paired
ledger entries a real transfer would eventually write. That reservation is specific: a transfer
would debit the sending tenant’s ledger with external id coal-<transferId>-debit and credit the
receiving tenant’s ledger with coal-<transferId>-credit (both recorded with source_system: "coalition") — the same ordinary ADJUST-shaped ledger entries every other point-granting
feature on this platform already writes, just paired across two tenants instead of written to one.
That pairing has been proven against the real ledger schema in tests — but no route, cron, or
service ever calls it, and for the application’s own database role this is structural rather than
just a missing button: app_rw — the role every request runs as — can neither insert nor update a
coalition_transfers row.
This is deliberate groundwork, not an oversight: designed, schema-complete, tested — and not built, pending signed merchant demand for the real product.
The privacy model
Coalitions are the platform’s first genuinely cross-tenant data — two businesses’ data sitting behind the same feature for the first time — so the isolation rules here are stricter than anywhere else in the API, not looser:
- No PII in any coalition table. The table that actually records a shared member
(
coalition_member_links) stores only a one-way phone-number hash and internal tenant/member ids — never a raw phone number, name, email, or any other directly identifying value. - Aggregates and your-own-member booleans only, always. Every read this feature exposes is
either a count (the overlap report) or a boolean on one of your own members
(
linked_elsewhere). There is no endpoint, field, or export anywhere that returns another tenant’s member-level data. - A business can never learn which partner shares a member. Not through the API, not
through any aggregate breakdown —
by_partner_countbuckets by how many partners a member is shared with, never which one. - Member lists show org-level facts only.
GET /v1/coalitions/:idshows you your partners’ tenant id, organization name, role, and status — the same kind of information you’d see about any business publicly — never anything about the members inside them. - This is enforced at the database layer, not only in application code. Every coalition table
carries Postgres row-level security — most of it scoped to actual coalition membership; the
table holding raw links (
coalition_member_links) goes further and denies ordinary tenant-scoped access outright, full stop. The overlap report and thelinked_elsewhereflag, computed by a separate system-level process, are the only two ways any of that underlying data ever reaches an API response, and both are pre-aggregated before they get there.
A GDPR-class note
Everything above describes an anonymous signal — a count, a yes/no flag — never a person’s identity crossing from one business to another. If a future version of this feature wanted to go further — actually sharing a member’s identity, contact details, or history between coalition partners, rather than an anonymous overlap count — that would be explicitly out of scope of this groundwork and would need its own member-consent machinery (the same class of provable, per-purpose consent this platform already requires before a marketing message goes out) before a single identifying record could move between businesses. v1 builds none of that. Stated plainly so it isn’t a surprise later: recognizing an overlap and sharing an identity are different features, and only the first one exists today.
API reference
| Method | Path | Auth |
|---|---|---|
POST |
/v1/coalitions |
dashboard or API key — settings:manage |
GET |
/v1/coalitions |
dashboard or API key — settings:read |
GET |
/v1/coalitions/:id |
dashboard or API key — settings:read |
POST |
/v1/coalitions/:id/invites |
dashboard or API key — settings:manage (owner only) |
POST |
/v1/coalitions/:id/accept |
dashboard or API key — settings:manage (the invited tenant) |
POST |
/v1/coalitions/:id/decline |
dashboard or API key — settings:manage (the invited tenant) |
POST |
/v1/coalitions/:id/leave |
dashboard or API key — settings:manage |
POST |
/v1/coalitions/:id/dissolve |
dashboard or API key — settings:manage (owner only) |
GET |
/v1/coalitions/:id/overlap |
dashboard or API key — settings:read (active members only) |
Full request/response schemas are in the API reference under the Coalitions
tag. linked_elsewhere itself rides on MemberResponse, under the Members tag, not a
Coalitions-tagged operation.
Errors
| Code | HTTP | When |
|---|---|---|
COALITION_ALREADY_MEMBER |
409 | Inviting a tenant that already holds any row — invited, active, or left — for this coalition. |
COALITION_OWNER_CANNOT_LEAVE |
409 | The owner tries to leave while another active member remains. |
INVALID_STATE |
409 | Accept/decline called with no current invite for that tenant. |
NOT_FOUND |
404 | The coalition doesn’t exist, you have no current membership row in it (including a still-invited tenant calling leave instead of decline), or — for the overlap report specifically — you’re invited but not yet active. Deliberately the same code in every case: telling a non-member “yes, this exists, you’re just not in it” would leak exactly what the privacy model above is built to prevent. |
FORBIDDEN |
403 | An active member who isn’t the owner calls an owner-only action (invite, dissolve). |
v1 limits
- No live point transfers. See What deliberately doesn’t ship above.
- No platform approval gate on coalition creation. Any tenant can create one and invite others.
- Phone-number linking only. Email identifiers aren’t compared yet.
- Overlap data is up to one night stale, in both directions — new links appearing, and old ones clearing after a leave or dissolve. Nothing recomputes on demand.
- No re-invite after leaving. A tenant that has left a coalition can’t be invited back into that same coalition in v1.
- Dashboard UI covers the recognition slice. Settings → Coalition creates a coalition,
invites partners by business ID, accepts/declines, leaves/dissolves, and shows the aggregate
overlap card; the members list and member detail carry the
linked_elsewherebadge. There is no live point-transfer UI — there is no live transfer.
Next steps
- API reference — the full schema for every
/v1/coalitionsendpoint, and thelinked_elsewherefield onMemberResponse. - Programs and Rewards — the ledger and member model coalitions are built on top of, without ever writing across it.
infra/DEPLOY.md— deploy-time notes for this feature (migrations through 0033, the sixth nightly leg, no new secrets — see its §8 smoke item).