RIFF · org-scoped membership · use-case-first spec · 2026-07-07 · status: DESIGN v1.1, Codex-reviewed (verdict: ORG; 6 findings folded) · siblings: session facts, session_facts module, org-telecom-identity.md
Org-Scoped Membership: every business keeps its own list
Born from a live incident (2026-07-07): erasing a caller "from the bike shop" erased them from
the law office too, because caller_identity is one global phone-keyed table. Membership is a
relationship between a PERSON and a BUSINESS — this design scopes it that way and tags every stored
row with the business it belongs to.
org_id = pack business.id (today 1:1 with flows; n:1 when org files land — the key
does not change).
membership(
org_id TEXT NOT NULL, -- business.id ("flow101_bluebonnet_bikes", "legal_intake")
phone TEXT NOT NULL, -- E.164
display_name TEXT,
sms_consent INTEGER, -- tri-state: NULL unknown / 1 granted / 0 DENIED (denial durable)
consent_at REAL, verified_at REAL,
enrolled_via_flow TEXT, -- provenance, not scope
created_at REAL, updated_at REAL,
PRIMARY KEY (org_id, phone))
intake_sessions += org_id TEXT -- every session row tagged; answers/transcripts
-- inherit via their token join
Runtime source of org_id: the compiled flow's org field (falls back to
flow_id for today's 1:1 packs). The dialed DID resolves to a flow, the flow names its org,
the org names its list.
Case B1 — Isolation is the default TB1
- A caller enrolls in the bike shop's Riders Club.
- They call the law office for the first time.
- The law office greets them as a stranger — no name, no consent, nothing.
Never: any business reading another business's membership row —
the resolver takes (org_id, phone); there is no phone-only lookup in the call path.
Case B2 — One phone, many memberships, different data TB2
The same number can be "Dave" with cruiser preferences at the bike shop and "David B. Marr" at the law office. Names, consents, and preferences never bleed between orgs. Updating one leaves the other byte-identical.
Case B3 — Erasure is per-org; compliance erase is everywhere TB3
- "Erase me from the bike shop" deletes the bike shop membership + the bike shop's tagged sessions /answers/transcripts — and NOTHING else. The law office still knows the caller.
- A compliance erase (
--all-orgs) removes every membership and every tagged row for the phone, and reports counts per org. - Suppression tombstones (Codex): erasing deletes CONTENT (name, preferences, sessions) but
retains a minimal suppression record
{org_id, phone_hash, sms_suppressed, erased_at}— "erase me" must never resurrect textability by erasing a durable opt-out. Tombstones carry no PII (hashed phone) and are readable only by the send gate.
Never: a per-org erase touching another org's rows; orphaned answers/transcripts surviving either erase (the token join is part of the delete, fixing the purge-tool gap found 2026-07-07); an erase that re-enables texting a caller who had opted out.
Case B4 — Consent is per-org and denial sticks TB4
- Caller grants SMS consent at the bike shop, denies at the law office.
- The bike shop's DID may text them; the law office's may not — the send path resolves consent by the org that owns the sending DID (composing with the existing fail-closed sms_from resolver).
- Denial is durable per org: the law office never re-asks on later calls; the bike shop is unaffected.
Never: consent granted to one brand authorizing a text from another.
Case B5 — Members survive new lines and flow renames TB5
The business adds a second flow (or renames/rebuilds one) under the same org_id: every
member is recognized on the new line with zero migration — because scope was never the flow.
Case B6 — Migration from the global table TB6
- Each existing
caller_identityrow becomes a membership in every org where that phone has a completed profile-intake session (provenance fromintake_sessions). - A phone with identity but NO attributable session gets a membership in the org of the flow that verified it if determinable, else it is reported for operator review — never silently dropped, never silently globalized.
- During a deprecation window the resolver reads membership FIRST, legacy global table second
(logged as
legacy_identity_hitso the window's end is data-driven); writes go ONLY to membership. Fallback discipline (Codex #2 — "the biggest incident risk"): the legacy fallback serves GREETINGS only; it is disabled in the send path and the erase path, metriced, and time-boxed — a phone-only read can never again decide who gets texted or what gets deleted. - Consent migrates conservatively (Codex #3):
sms_consent=1copies only where org-specific evidence exists (a consent answer row in that org's sessions); otherwise the membership startsNULL(asked once, fresh). A legacy denial becomes per-org suppression wherever attribution is plausible.
Case B7 — The session_facts tie-in TB7
The fact registry gains a scope column: identity and
sms_consent become scope: org; selected_route/journey
stay scope: call. auth_level=known now means "this org knows you."
The membership desk (option 0) is the writer of org-scoped facts; the transport prefill reads only
the dialed org's row.
Edge-case register (Codex #5 — specified now, most deferred with owners)
| edge | v1 ruling |
|---|---|
| Phone canonicalization | REQUIRED in v1: all keys pass normalize_caller_phone (E.164); the PK is only sound canonicalized (Codex PK finding) |
| Number reassignment | v1: verified_at staleness threshold — greetings degrade to "is this still <name>?" re-confirm past it; full reuse signals deferred |
| Org merge / split / franchise | Deferred to org files (PR4): a mapping table migration, possible because every row is org-tagged |
| Transactional vs marketing SMS | v1 sends are all transactional (follow-along/status); a consent_kind column is reserved before any marketing use |
| Audit: "why could this org text this phone?" | v1: every send already writes a ledger row; it gains consent_evidence (membership row id or tombstone check) |
Out of scope (v1), recorded deliberately
- Cross-business "RIFF ID" (opt-in platform identity) — a future layer on top, never a default.
- Org files (PR4) — this design keys on
business.idnow and is unchanged by org files landing. - Membership tiers/points — the schema leaves room (a later
tiercolumn), not built.
Test matrix (TDD — written red before implementation)
| id | case | asserts |
|---|---|---|
| TB1 | isolation | enroll(orgA); resolve(orgB, phone) ⇒ unknown; no phone-only lookup API in the call path |
| TB2 | divergent data | two orgs, same phone, different names/consents; update A ⇒ B byte-identical |
| TB3 | erasure | per-org erase leaves other org intact + zero orphans; --all-orgs erases everywhere with per-org counts |
| TB4 | consent | send-path consent joins the sending DID's org; denied org never re-asked, never texted |
| TB5 | org continuity | second flow with same org_id recognizes existing member |
| TB6 | migration | provenance-attributed copy; unattributable rows reported not dropped; reads fall back, writes never do |
| TB7 | facts scope | registry carries scope; prefill reads dialed org only |