Multi-Tenant Architecture Plan
Executive Summary
Foundation's platform is designed to serve individual communities. To unlock organizational adoption — municipal pilots (Austin TX), enterprise deployments (Amazon, federal government), and institutional partnerships — the platform needs multi-tenant support where each organization operates in an isolated governance space while sharing the same infrastructure.
The current codebase is closer to multi-tenant than it appears. The Solana program already has a population field that scopes voters and proposals. The templateService.ts uses communityId: 'default'. The migration path is evolutionary, not revolutionary.
1. Current Architecture
What Exists Today
| Layer | Current State | Tenant-Ready? |
|---|---|---|
| Firestore | 17+ flat top-level collections, no tenant partitioning | No |
| Solana program | population: String on VoterAccount and Proposal with cross-check (voter.population == proposal.population) |
Partial — already a tenant discriminator |
| Firebase Auth | Email-link invite-only; custom claims include sub, email, ring, role, tenantId |
Partial — tenantId claim stamped by beforeUserCreated trigger (post-2026-04-20) |
| Frontend routing | Single SPA, no tenant prefix | No |
| Templates/Constitution | communityId: 'default' pattern already seeded |
Proto-tenant |
| Security rules | Wide-open devnet rules, no auth checks | No |
| Cloud Functions | Most callables use bare collection names, no tenant context | No |
Collections That Need Tenant Scoping
All user-facing collections: proposals, voters, votes, supporter_signatures, voting_rounds, funds, distributions, product_requests, savings_summary, action_items, anonymous_votes, semaphore_groups, identity_proofs (special — see Section 4).
Collections that stay global: access_codes, access_grants, mail, webauthn_credentials (auth infrastructure).
2. Architecture Options
Option A: Tenant Field on Every Document (Recommended for Phase 1)
Add tenant_id: string to every document. All queries gain a where('tenant_id', '==', currentTenant) clause.
// Every query becomes:
const q = query(
collection(db, 'proposals'),
where('tenant_id', '==', getTenantId()),
orderBy('created_at', 'desc')
);
| Pros | Cons |
|---|---|
| Minimal structural change | Must add filter to every query — miss one and data leaks |
| Single Firestore project | Composite index count grows |
| Easy cross-tenant analytics | No hard isolation at DB level |
| Works with existing security rules structure | — |
Precedent: Decidim (Barcelona) and Consul (Madrid), both open-source civic governance platforms, use this exact pattern (organization_id / tenant_id column on every table).
Option B: Subcollection per Tenant
Structure: tenants/{tenantId}/proposals/{proposalId}, etc.
| Pros | Cons |
|---|---|
| Natural Firestore hierarchy | Larger structural migration |
| Security rules gate at tenant level | Collection group queries needed for cross-tenant views |
| No risk of forgetting where clause | Every Firestore path changes |
Option C: Separate Firebase Projects per Tenant
Each tenant gets its own Firebase project.
| Pros | Cons |
|---|---|
| Strongest isolation | Massive operational overhead |
| Billing separation | Cannot share auth across tenants |
| Independent scaling | Deployment complexity multiplied |
Recommendation
Phase 1: Option A — minimum change, maximum velocity. The communityId: 'default' pattern is already seeded.
Phase 2: Migrate to Option B if stronger isolation is needed (regulatory, enterprise requirements).
Option C: Only if data residency requirements demand it (e.g., EU-only tenant).
3. What Changes, Layer by Layer
3.1 Data Model
New collection: tenants
interface Tenant {
id: string;
name: string; // "Austin TX YourVoice Pilot"
slug: string; // "austin-tx"
type: 'municipal' | 'enterprise' | 'community' | 'pilot';
plan: 'free' | 'pro' | 'enterprise';
ownerWallet: string;
config: {
branding?: { name: string; logo?: string; primaryColor?: string };
governance?: { defaultThreshold: number; votingDurationHours: number };
features?: { pillar1: boolean; pillar2: boolean; pillar3: boolean };
};
createdAt: string;
memberCount: number;
}
Every existing document type gains:
tenant_id: string; // References tenants collection
Data migration script: Add tenant_id: 'default' to all existing documents.
3.2 Tenant Context Module
New file: lib/tenant.ts
let _currentTenantId: string = 'default';
export function setTenantId(id: string) { _currentTenantId = id; }
export function getTenantId(): string { return _currentTenantId; }
// Wrapper that enforces tenant filter on every query
export function tenantQuery(collectionName: string, ...constraints: QueryConstraint[]) {
return query(
collection(db, collectionName),
where('tenant_id', '==', getTenantId()),
...constraints
);
}
3.3 Database Layer (Frontend)
File: src/lib/database.ts — approximately 35 query/write sites to retrofit.
Every collection(db, 'X') call wraps through tenantQuery():
// Before:
const q = query(collection(db, 'proposals'), orderBy('created_at', 'desc'));
// After:
const q = tenantQuery('proposals', orderBy('created_at', 'desc'));
Every write includes tenant_id:
// Before:
await setDoc(doc(db, 'proposals', id), proposalData);
// After:
await setDoc(doc(db, 'proposals', id), { ...proposalData, tenant_id: getTenantId() });
Files affected: database.ts, identityProofs.ts, templateService.ts, constitution.ts, validateFirestore.ts, testTools.ts, seedDemoData.ts
3.4 Backend (Cloud Functions)
Section 3.4 originally targeted the retired REST gateway. The tenant-claim work has since moved to Cloud Functions:
beforeUserCreatedandbeforeUserSignedIntriggers stamp atenantIdcustom claim; callables read it via the auth middleware.
JWT claims (retained for historical reference — pre-cutover design): Add tenant_id to JwtClaims struct.
pub struct JwtClaims {
pub sub: String,
pub wallet: String,
pub tenant_id: String, // NEW
pub credential_id: String,
pub verified: bool,
pub exp: usize,
}
Firestore writes: Every firestore.rs method adds tenant_id to document data.
API handlers: Validate that tenant_id in request matches JWT claim.
Files affected: auth.rs, firestore.rs, proposals.rs, votes.rs, supports.rs, voters.rs
3.5 Frontend Routing
Phase 1: Path-based routing (simplest, works with single Firebase Hosting site).
foundation.vote/t/austin-tx/proposals
foundation.vote/t/amazon-ergs/proposals
foundation.vote/t/default/proposals (current community)
React Router change:
<Route path="/t/:tenantSlug/*" element={<TenantApp />} />
TenantApp resolves slug → tenant ID on mount, calls setTenantId(), renders existing app shell.
Phase 2: Subdomain routing (better UX, requires DNS wildcard).
austin-tx.foundation.vote/proposals
amazon-ergs.foundation.vote/proposals
Requires Cloudflare (or similar) reverse proxy for wildcard subdomain → single Firebase Hosting site.
3.6 Security Rules
Phase 1 (devnet — relaxed but tenant-scoped):
match /proposals/{proposalId} {
allow read: if resource.data.tenant_id == request.auth.token.tenant_id;
allow create: if request.resource.data.tenant_id == request.auth.token.tenant_id;
allow update: if resource.data.tenant_id == request.auth.token.tenant_id;
allow delete: if request.auth.token.role == 'tenant_admin'
&& resource.data.tenant_id == request.auth.token.tenant_id;
}
Phase 2 (production — fully locked):
match /tenants/{tenantId} {
allow read: if request.auth != null;
allow write: if request.auth.token.role == 'platform_admin';
}
match /{collectionName}/{docId} {
allow read: if resource.data.tenant_id == request.auth.token.tenant_id;
allow create: if request.resource.data.tenant_id == request.auth.token.tenant_id;
allow update: if resource.data.tenant_id == request.auth.token.tenant_id;
}
3.7 Solana Program
The Anchor program's population field is already a tenant discriminator:
pub struct Proposal {
pub population: String, // Already exists — treat as tenant_id
pub title: String,
// ...
}
// Already enforced:
require!(voter_account.population == proposal.population, VotingError::Unauthorized);
Phase 1: Use population as-is. Frontend maps tenant_id → population when constructing transactions.
Phase 2: Add explicit tenant_id to PDA seeds to prevent cross-tenant collisions:
// Current: [b"proposal", title.as_bytes(), creator.key()]
// Phase 2: [b"proposal", tenant_id.as_bytes(), title.as_bytes(), creator.key()]
4. Identity: Cross-Tenant Verification
A person should verify their identity once and join multiple tenants (e.g., vote in both their city governance and their employer's ERG).
Architecture
identity_proofs (GLOBAL — one per passport)
├── nullifier: "abc123..."
├── commitment: "..."
├── proofType: "self-passport"
├── verifiedAt: "2026-04-13..."
└── trustTier: "high"
tenant_memberships (PER-TENANT)
├── voter_id: "voter-xyz"
├── tenant_id: "austin-tx"
├── identity_proof_nullifier: "abc123..."
├── role: "member" // or "admin", "observer"
├── joinedAt: "2026-04-13..."
└── status: "active"
identity_proofsstays global: one passport = one proof = one nullifiertenant_membershipsmaps verified voters to tenants they've joined- Verification status = "has identity proof" AND "has membership in this tenant"
- Semaphore groups: scoped to
tenant_id + proposal_id(not justproposal_id)
5. Phased Implementation
Phase 1: Minimum Viable Multi-Tenant
Goal: Single codebase, single Firestore project, data logically partitioned. Existing "default" tenant works unchanged. New tenants created manually via admin tools.
Layers of work (ordered):
- Tenant data model — Create
tenantscollection andTenanttype. Seeddefaulttenant for existing data. - Tenant context module —
lib/tenant.tswithgetTenantId()/setTenantId(), initialized from URL path. - Database layer retrofit — Add
tenant_idfilter to every query indatabase.tsand related modules (~35 query/write sites). - Backend retrofit — Add
tenant_idto JWT claims and every Firestore write in Rust backend. - Path-based routing —
/t/:tenantSlug/prefix in React Router. - Data migration script — Add
tenant_id: 'default'to all existing documents. - Composite indexes — Update
firestore.indexes.jsonfor queries that includetenant_id. - Validator/Admin tools — Update DB Validator, seeders, and admin tools to be tenant-aware.
Phase 2: Full Isolation
Goal: Hardened security, tenant admin roles, cross-tenant identity.
- Security rules overhaul — Tenant-scoped rules requiring
request.auth.token.tenant_id. - Tenant admin role — JWT role field (
member,admin,tenant_admin,platform_admin). - Cross-tenant identity — Separate
identity_proofs(global) fromtenant_memberships(per-tenant). - Solana program update — Add
tenant_idto PDA seeds. - Per-tenant branding — Name, logo, color scheme from
tenants/{id}doc. - Subdomain routing — Wildcard DNS + reverse proxy.
Phase 3: Self-Service Provisioning
Goal: Any organization can create their own tenant through the UI.
- Tenant creation flow — UI wizard for creating new tenant.
- Onboarding wizard — Guide tenant admin through constitution, governance config, first members.
- Billing and limits — Per-tenant quotas, Stripe integration for paid tiers.
- Tenant API keys — Programmatic access for each tenant.
- Tenant export/deletion — GDPR compliance.
- Multi-region — Separate Firestore instances for data residency if needed.
6. Effort Surface Area
Phase 1 Touch Points
| Layer | Files | Scope |
|---|---|---|
| Tenant context module | 1 new (lib/tenant.ts) |
getTenantId, setTenantId, tenantQuery wrapper |
| Frontend database queries | 7 files | ~35 query/write sites get tenant filter |
| Frontend types | 4 files | Add tenantId field to entity types |
| Frontend routing | 2 files | Router config + App.tsx |
| Backend JWT | 1 file (auth.rs) |
Add tenant_id claim |
| Backend Firestore | 1 file (firestore.rs) |
~6 methods |
| Backend API handlers | 4 files | Tenant validation |
| Firestore rules | 1 file | Add tenant checks |
| Firestore indexes | 1 file | Add tenant_id prefix to composites |
| Data migration | 1 new script | Backfill tenant_id on all docs |
| Admin tools | 3 files | Validator, seeders, reset tools |
| Total | ~26 files | ~60 change sites |
Key Risk Mitigations
| Risk | Mitigation |
|---|---|
| Query leakage (forgot tenant filter → cross-tenant data exposure) | tenantQuery() wrapper enforces filter; lint rule flags raw collection() calls |
| Index explosion (200-index Firestore limit) | Audit shows most queries are simple; adding tenant_id prefix is manageable |
| Solana PDA collisions (same title + creator across tenants) | Frontend UUID for proposal_id avoids this; Phase 2 adds tenant to PDA seeds |
| Cross-tenant identity confusion | Identity proofs global; tenant membership is separate join |
7. Example: Austin TX Pilot
Setup
Platform admin creates tenant:
{ "name": "Austin TX YourVoice Pilot", "slug": "austin-tx", "type": "municipal", "plan": "pilot", "config": { "branding": { "name": "Austin YourVoice", "primaryColor": "#004B87" }, "governance": { "defaultThreshold": 100, "votingDurationHours": 168 }, "features": { "pillar1": true, "pillar2": false, "pillar3": false } } }Austin residents access:
foundation.vote/t/austin-tx/They verify identity (same PoH process), which creates:
- Global
identity_proof(if first verification) tenant_membershiplinking them toaustin-tx
- Global
Austin city council creates proposals scoped to
austin-txtenantOnly Austin members can vote on Austin proposals
Foundation admin dashboard shows all tenants, cross-tenant analytics
Data Isolation
Austin users cannot see Amazon ERG proposals. Amazon ERG users cannot vote on Austin proposals. But a person who works at Amazon and lives in Austin can be a member of both tenants with a single verified identity.
8. Reference Architectures
| Platform | Pattern | Scale | Notes |
|---|---|---|---|
| Decidim (Barcelona) | organization_id on every table |
400+ orgs globally | Open source, PostgreSQL |
| Consul (Madrid) | tenant_id column |
Multiple municipalities | Open source, PostgreSQL |
| Loomio (NZ) | Separate DB per tenant (PG schemas) | Thousands of groups | Stronger isolation, higher ops cost |
| Firebase Auth Identity Platform | Built-in tenant support | Google-scale | auth().tenantManager().createTenant() — per-tenant user pools |
Document version: 2026-04-13 | Prepared for YC S26 application