Foundation — Architecture Flows
End-to-end flows for the three load-bearing subsystems of Foundation (SolanaVote, Devnet alpha):
- Authentication — invite-only, email-link, ring-based, per-user custodial wallet
- Proof of Humanity (PoH) — ePassport ZK, OCR fallback, manual review
- Soulbound Attestations — issue / query / verify (GaaS) / re-mint / revoke
Diagrams are Mermaid. They render on GitHub and in the docs site. Source of truth is the code under
functions/ and evoting-frontend/src/; this document is a map, not a spec — read the code when in doubt.
1. Authentication Flow
Identity is Firebase Auth only (the prior REST gateway was retired in the Phase 4/5 cutover). Access is
invite-only: anyone can request access, but only an admin-approved invite lets an email complete sign-in.
Authorization is ring-based via @plantagoai/auth.
Rings
| Ring |
Name |
Typical capability |
| 0 |
PLATFORM_OWNER |
Full admin: API keys, revoke attestations, user management |
| 1 |
TENANT_ADMIN |
Manage a tenant's proposals, members |
| 2 |
PRIVILEGED |
Steward-level actions |
| 3 |
USER |
Vote, support, submit proposals |
| 4 |
RESTRICTED |
Observer / limited |
Middleware: requireAuth(request) (any signed-in user) and requireRing(request, Ring.X) (ring ≤ X) in
every Cloud Function.
Sequence: request → invite → sign-in → wallet
sequenceDiagram
autonumber
actor U as Visitor
participant FE as Frontend (React)
participant CF as Cloud Functions
participant FS as Firestore
participant RS as Resend (email)
participant FA as Firebase Auth
Note over U,FA: Phase A — Request access
U->>FE: Submit RequestAccessForm (email)
FE->>FS: create access_request
Note over U,FA: Phase B — Admin approves
participant AD as Admin (Ring 0/1)
AD->>FE: Approve request (AdminAccessRequestsTab)
FE->>FS: write invites/{email} (with ring)
FS-->>CF: onCreate invites/{email} → sendInviteEmail
CF->>FA: generate email-link sign-in URL
CF->>RS: send branded invite email (link)
RS-->>U: Invite email with sign-in link
Note over U,FA: Phase C — Complete sign-in
U->>FE: Click link → /finishSignIn
FE->>FA: completeSignInFromUrl (signInWithEmailLink)
FA->>CF: beforeUserCreated → checkInviteOnSignup
CF->>FS: verify invites/{email} exists
alt no invite
CF-->>FA: reject (block account creation)
FA-->>U: Access denied
else invited
CF-->>FA: allow + stamp custom claims (ring, role)
end
FA->>CF: beforeUserSignedIn → backfillTenantClaim
CF-->>FA: patch claims (tenantId, access)
FA-->>FE: session (ID token w/ ring claims)
FE->>FE: ToS acceptance gate
Note over U,FA: Phase D — Custodial wallet (lazy)
FE->>CF: getMyWallet
CF->>CF: getUserKeypair(uid) — idempotent
alt first time
CF->>CF: generate keypair, KMS envelope-encrypt
CF->>FS: persist user_wallets/{uid}
CF->>CF: fund via devnet airdrop
end
CF-->>FE: { publicKey } (on-chain identity)
State: account lifecycle
stateDiagram-v2
[*] --> Anonymous
Anonymous --> Requested: submit RequestAccessForm
Requested --> Invited: admin approves → invites/{email}
Invited --> SignedIn: click link + checkInviteOnSignup passes
Anonymous --> SignedIn: existing user requests fresh link (resendInviteLink)
SignedIn --> ToSAccepted: accept current ToS
ToSAccepted --> WalletProvisioned: getMyWallet (first call)
WalletProvisioned --> Active
Active --> [*]: deleteMyAccount (90-day grace)
Key guarantees
- The
beforeUserCreated blocking trigger is the hard gate — a stranger can request a link, but account
creation is refused unless invites/{email} exists.
- Custom claims (
ring, role, tenantId, access) live on the Firebase ID token and are re-applied by
the beforeUserSignedIn backfill so older accounts stay consistent.
- The wallet secret never leaves the server (KMS envelope encryption); the frontend only ever sees the pubkey.
2. Proof of Humanity (PoH) Flow
Corrected 2026-09-05. This section described the retired Self Protocol
path (verifyPassportProof, deleted 2026-08-31). It now follows
docs/identity-architecture-current.md §1, which is the verified source —
re-check that doc, not this diagram, before citing a line number.
Foundation gates governance on one unique person, one voice. A person proves humanity once; the result is
a soulbound VERIFIED_HUMAN attestation (see §3). Three enrollment paths, by trust tier.
Enrollment paths
flowchart TD
Start([User opens Identity / PoH]) --> Q{Has ePassport?}
Q -->|Yes| RariMe[Scan QR with RariMe app]
Q -->|No ePassport| OCR[OCR document hash fallback]
Q -->|No supported doc| MR[Submit documents for manual review]
RariMe --> ZK[RariMe builds ZK proof on-device<br/>Groth16 over ICAO 9303 passive-auth chip data]
ZK --> VSV[Proof lands at self-hosted verificator-svc]
VSV --> Poll[Client polls getL2VerificationStatus]
Poll --> Verify{verificator-svc reports verified?}
Verify -->|No / pending| Reject([Not yet verified])
Verify -->|Yes| Null[Read proof nullifier + disclosures]
Null --> Sybil{nullifier already used?}
Sybil -->|Yes, different voter| Dup([NULLIFIER_ALREADY_USED])
Sybil -->|No| Abuse{voter abuse-flagged?}
Abuse -->|Yes| Flagged([VOTER_FLAGGED_FOR_ABUSE])
Abuse -->|No| Proof[mintPohRoot writes identity_proofs/{nullifier}<br/>provider=rarimo · trustTier=high]
OCR --> ProofL[Write identity_proof · trustTier=medium]
MR --> Admin{Admin approves?}
Admin -->|No| RejectM([Rejected])
Admin -->|Yes| ProofM[approveManualReviewImpl writes identity_proof · trustTier=low]
Proof --> Att[Enqueue VERIFIED_HUMAN attestation]
ProofL --> Att
ProofM --> Att
Att --> Done([Soulbound VERIFIED_HUMAN minted])
Sequence: RariMe passport ZK enrollment (high trust)
sequenceDiagram
autonumber
actor U as User
participant RM as RariMe App
participant SLV as startL2Verification (HTTP)
participant VSV as verificator-svc (self-hosted)
participant GLV as getL2VerificationStatus (poll)
participant FS as Firestore
participant Q as Cloud Tasks
U->>SLV: Request verification link
SLV->>VSV: Create verification session
VSV-->>SLV: RariMe deep link
SLV-->>U: Render QR
U->>RM: Scan QR with RariMe
RM->>RM: Read passport chip (NFC), build ZK proof on-device
RM->>VSV: Submit proof (on-device — no passport data leaves the device)
U->>GLV: Poll status
GLV->>VSV: Check session status
VSV-->>GLV: verified · proof nullifier + disclosures
GLV->>FS: abuse-registry gate (isVoterAbuseFlagged)
GLV->>FS: read identity_proofs/{nullifier}
alt nullifier exists, different voter
GLV-->>U: 409 NULLIFIER_ALREADY_USED
else fresh
GLV->>FS: mintPohRoot writes identity_proofs/{nullifier}<br/>(provider=rarimo, proofType=rarimo-passport, trustTier=high, disclosures)
GLV->>Q: enqueueIssueAttestation(wallet, VERIFIED_HUMAN, zeros)
GLV-->>U: verified
end
Notes
- Scope binding:
RarimoPassportProvider takes an eventId (a fixed environment value, or a per-call
override) — the resulting nullifier is scoped to that event, so a proof for one context (e.g. registration)
doesn't collide with a proof for another (e.g. a specific poll). See
functions/founders/rarimo-passport-provider.js.
- No raw PII: only bounded categorical disclosures are stored (
humanity, age as 18+, jurisdiction
as nationality code) — never the document contents. The proof itself is built entirely on-device in RariMe;
no passport data reaches Foundation's servers.
- Trust tiers: RariMe ePassport ZK = high trust; OCR document hash = medium; manual review = low. The tier
is recorded on the identity proof and surfaced in the UI ("High trust · ePassport ZK").
GaaS — third-party humanity check
Other governance platforms verify a Foundation user's humanity without seeing their wallet (see §3.3).
3. Attestation Flows
Soulbound (non-transferable) Solana PDAs on the attestations program GQrFse7…KGky (devnet).
- PDA seed:
["attestation", holder_wallet, attestation_type, context_hash] → deterministic per
(holder, type, context).
- Types:
0 = VERIFIED_HUMAN (context = 32 zero bytes), 1 = VOTED, 2 = SUPPORTED_PROPOSAL
(context = the proposal's on-chain PDA bytes, so each proposal yields a distinct attestation).
- Firestore mirror:
attestations/{wallet}_{type}_{contextHex} with status (pending → confirmed),
onChainAddress (the PDA), onChainTxSig.
3.0 Overview
flowchart LR
subgraph Triggers
PoH[PoH verified] --> EH[enqueue VERIFIED_HUMAN]
Vote[castProposalVote] --> EV[enqueue VOTED · proposal PDA]
Sup[castProposalSupport] --> ES[enqueue SUPPORTED · proposal PDA]
Refresh[refreshAttestation] --> ER[re-derive full set]
end
EH --> Q[(Cloud Tasks:<br/>issueAttestationOnChainTask)]
EV --> Q
ES --> Q
ER --> Q
Q --> Mint[deriveAttestationPDA → issueAttestationOnChain]
Mint --> Stamp[(attestations/* · confirmed)]
Stamp --> Query[queryAttestations / GaaS / verifyPoh]
Stamp --> Revoke[revokeAttestation → close PDA]
3.1 Issuance — the on-chain task pipeline
Every attestation is minted through one idempotent Cloud Task. Issuance is always non-fatal to its trigger
(a vote still counts even if its attestation enqueue fails).
stateDiagram-v2
[*] --> Enqueued: enqueueIssueAttestation(wallet, type, contextHash)
Enqueued --> Running: issueAttestationOnChainTask
Running --> Skip: Firestore doc already has onChainAddress
Skip --> [*]
Running --> Mint: derive PDA, issueAttestationOnChain (Anchor)
Mint --> Confirmed: write attestations/* (status=confirmed, onChainAddress, txSig)
Mint --> Adopt: tx fails "already in use" / OnChainAlreadyAnchored
Adopt --> Confirmed: adopt existing PDA, stamp Firestore (txSig=null)
Mint --> Retry: transient error
Retry --> Running: Cloud Tasks backoff (≤24h)
Retry --> DLQ: permanent error or budget exhausted
Confirmed --> [*]
DLQ --> [*]
The Adopt transition is the idempotency fix: when a PDA already exists on-chain but its Firestore doc is
missing (earlier mint that didn't stamp, or a deleted doc), the task adopts the deterministic PDA instead of
failing on the System Program's "account already in use" error.
3.2 Issuance triggered by a vote (VOTED)
SUPPORTED_PROPOSAL is identical with castProposalSupport / mirrorSupportOnChainTask.
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend
participant CV as castProposalVote (callable)
participant FS as Firestore
participant QM as mirrorVoteOnChainTask
participant QA as issueAttestationOnChainTask
participant SOL as Solana (devnet)
U->>FE: Cast vote
FE->>CV: { proposalId, optionId }
CV->>FS: tx: dedup + write votes/* + bump tallies
CV->>QM: enqueue mirror (anchor the vote)
alt proposal has on-chain twin
CV->>QA: enqueue VOTED (context = proposal PDA)
else no twin yet
Note over CV: skip — mirror task backstops once twin exists
end
CV-->>FE: { status: recorded }
QM->>SOL: submit vote on-chain
QM->>QA: enqueue VOTED (context = proposal PDA) — backstop
QA->>SOL: deriveAttestationPDA + issueAttestation
QA->>FS: attestations/* → confirmed
3.3 Verification — query, GaaS endpoint, in-app verifier
Three read paths share one core (runPohQuery): direct callable, third-party HTTP (API-key), and an
authenticated in-app verifier.
flowchart TD
subgraph "In-app (authed user)"
QA1[queryAttestations] --> Core
VP[verifyPoh · mode token/email/wallet] --> Core
end
subgraph "Third-party (GaaS)"
EP[pohAttestationsEndpoint] --> Key{valid X-PoH-API-Key?}
Key -->|no| K401[401]
Key -->|yes| Core
end
Core[runPohQuery: resolve subject → wallet] --> Mode{lookup mode}
Mode -->|wallet| W[direct · wallet returned]
Mode -->|email| E[Firebase Auth → wallet · wallet hidden]
Mode -->|token| T[poh_tokens/{uuid} → wallet · wallet hidden]
W --> QF[query confirmed attestations]
E --> QF
T --> QF
QF --> Resp[{ attestations, count, hasVerifiedHuman, wallet? }]
sequenceDiagram
autonumber
actor TP as Third-party platform
actor U as Foundation user
participant GT as generatePohToken (callable)
participant FS as Firestore
participant EP as pohAttestationsEndpoint (HTTP)
U->>GT: generate one-time token (24h TTL)
GT->>FS: poh_tokens/{uuid} → wallet
GT-->>U: token
U->>TP: hand over token (never the wallet)
TP->>EP: GET ?token=… (X-PoH-API-Key: …)
EP->>FS: validate API key (poh_api_keys, SHA-256)
EP->>FS: poh_tokens/{token} → wallet (TTL + active checks)
EP->>FS: query confirmed attestations
EP-->>TP: { hasVerifiedHuman, count, attestations } (no wallet)
Lookup-mode privacy
| Mode |
Param |
Wallet in response? |
| Wallet |
?wallet=<base58> |
yes |
| Email |
?email=<addr> |
no (server resolves) |
| Token |
?token=<uuid> |
no (opaque, 24h TTL) |
3.4 Re-mint / self-heal (refreshAttestation)
When on-chain coverage lags Firestore activity (e.g., tasks lost to an earlier bug), the panel triggers a
re-derive. Idempotent — already-minted attestations are skipped.
sequenceDiagram
autonumber
participant FE as AttestationsPanel
participant RA as refreshAttestation (callable)
participant BF as reissueUserAttestations
participant FS as Firestore
participant Q as Cloud Tasks
FE->>FE: on mount — onChain count < activity count?
FE->>RA: refreshAttestation() (once per mount)
RA->>BF: reissueUserAttestations(db, uid)
BF->>FS: identity_proofs? → VERIFIED_HUMAN (zeros)
BF->>FS: distinct voted proposals → VOTED per proposal w/ twin
BF->>FS: distinct supported proposals → SUPPORTED per proposal w/ twin
BF->>Q: enqueue each (skips proposals without an on-chain twin)
BF-->>RA: { queued, skipped }
RA-->>FE: done (task pipeline mints + stamps)
3.5 Revoke (admin)
sequenceDiagram
autonumber
actor AD as Admin (Ring 0)
participant RV as revokeAttestation (callable)
participant SOL as Solana
participant FS as Firestore
AD->>RV: { walletAddress, attestationType, contextHashHex }
RV->>RV: requireRing(PLATFORM_OWNER)
RV->>SOL: revokeAttestationOnChain → close PDA (reclaim rent)
RV->>FS: mark attestations/* revoked
RV-->>AD: { success }
3.6 GaaS API key management (admin)
stateDiagram-v2
[*] --> Created: createPohApiKey (label)
note right of Created
random 32-byte key shown once
only SHA-256 hash persisted
end note
Created --> Active
Active --> Listed: listPohApiKeys (hash + metadata only)
Active --> Revoked: revokePohApiKey (active=false)
Revoked --> [*]
4. Your Market — commerce-agent bulk-buy
Current engineer reference: Your Market architecture — current.
This flow is in addition to the vote-on-bids marketplace (product_requests /
castProductVote). It does not move a Share community fund and does not run a Voice vote.
sequenceDiagram
autonumber
actor M as Verified member
participant Pool as Pool agent
participant CF as functions/market.js
participant Eng as lib/market/engine.js
actor Ops as Source / admin
M->>Pool: "open bulk buys" / Join card
Pool->>CF: runMarketAgent (pool) or commitToMarketPool
CF->>CF: requireAuth + requireVerifiedMember
CF->>Eng: commitDemand (serialized per pool)
alt fill ≥ 80% and known supplier rating ≥ 4.0
Eng->>Eng: RFQ + auto-award + allocate checkout
M->>CF: placeMarketOrder (host nonce)
else thin pool or rating below floor
Eng->>Ops: review queue
Ops->>CF: applyMarketAction
CF->>Eng: applyStaged (re-check ceiling/rating now)
end
Harness (code, not prompt): session-issued IDs, <untrusted-commerce-data> fence, no charge tool, Source sees aggregates only.
Firestore collections (touched by these flows)
| Collection |
Written by |
Purpose |
invites/{email} |
admin approval |
invite gate for checkInviteOnSignup |
user_wallets/{uid} |
getUserKeypair |
KMS-encrypted custodial keypair |
identity_proofs/{nullifier} |
mintPohRoot (via getL2VerificationStatus) / manual approve |
PoH record (one per passport) |
votes, supporter_signatures |
castProposalVote / castProposalSupport |
governance activity |
attestations/{wallet}_{type}_{ctx} |
issueAttestationOnChainTask |
on-chain attestation mirror |
poh_tokens/{uuid} |
generatePohToken |
opaque GaaS lookup tokens (24h) |
poh_api_keys/{id} |
createPohApiKey |
hashed GaaS API keys |
market_pools, market_commitments, market_orders |
commitToMarketPool / placeMarketOrder |
bulk-buy demand + host checkout |
market_quotes, market_review_queue |
policy auto-apply / applyMarketAction |
RFQ + exception path |
Cloud Functions index (by flow)
- Auth:
sendInviteEmail, checkInviteOnSignup, backfillTenantClaim, resendInviteLink, getMyWallet
- PoH:
startL2Verification, getL2VerificationStatus, manual-review approval (user-management)
- Attestations:
issueAttestationOnChainTask, mirrorVoteOnChainTask, mirrorSupportOnChainTask,
queryAttestations, verifyPoh, pohAttestationsEndpoint, refreshAttestation, revokeAttestation,
generatePohToken, createPohApiKey, listPohApiKeys, revokePohApiKey
- Market commerce agents:
runMarketAgent, commitToMarketPool, placeMarketOrder, applyMarketAction,
listMarketMemory, correctMarketMemory, deleteMarketMemory, seedMarketDemo