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 }
}

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:

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

Next steps