Identity architecture — current
Status: current as of 2026-09-03. Supersedes the identity sections of
docs classified HISTORICAL in docs/doc-status-triage-2026-09-03.md.
Every claim below was checked against source in this worktree (plan6-b-task2,
based on main at b7b36642) — not restated from an earlier design doc or
from memory. Every file:line citation was independently re-verified with
grep -n/sed -n in a second pass after a first draft (which had drifted on
a few of them). Re-verify again before citing a specific line number in a PR
review months from now — line numbers drift with unrelated edits even when
the function itself hasn't moved.
1. Enrollment
A member proves humanity once, with a passport, through RariMe (Rarimo's
mobile proving app) and Foundation's own self-hosted verificator-svc
instance. There are two callables involved, and it matters which one
actually mints the root — the module docstring at
functions/founders/passport.js:2-19 names both:
startL2Verification(functions/founders/passport.js:109) requests a verification link fromverificator-svcand returns a RariMe deep link for the client to render as a QR code.getL2VerificationStatus(functions/founders/passport.js:159, re-exported alongsidestartL2Verificationatfunctions/index.js:3847) is the poller the client calls afterward. Per the flow comment atfunctions/founders/passport.js:14-18: "browser renders QR → user scans with RariMe → app reads the passport chip and proves ON-DEVICE → proof lands at our self-hosted verificator-svc → this poll observes 'verified', fetches the proof's nullifier, and runs the same member-creation semantics as the L1 card lane. No passport data ever reaches Foundation servers." It isgetL2VerificationStatus, notstartL2Verification, whose body (functions/founders/passport.js:304) callsmintPohRoot— the QR-request call and the mint happen in two different exported functions.- The passport's ZK proof (a Groth16 query-circuit proof over the ICAO 9303
passive-authentication chip data) is generated on the RariMe app on the
user's own device; the docstring line quoted above states plainly that no
passport data reaches Foundation servers. That said, the proof's public
signals do carry selectively-disclosed attributes — nationality, sex,
and an age-lower-bound — which
getL2VerificationStatusreads back and passes intomintPohRootasdisclosures(seebuildPohDisclosures,functions/founders/passport.js:94). "No raw passport data leaves the device" is accurate; "nothing about the person is disclosed" is not — the selectively-disclosed fields are the whole point of the proof.functions/founders/rarimo-passport-provider.js:1-14documents the HTTP client itself as talking to "a self-hosted rarimo/verificator-svc (MIT)"; the nullifier ispub_signals[0]of the proof (functions/founders/rarimo-passport-provider.js:11-14). - On a successful claim,
functions/founders/passport.js:304callsmintPohRoot(db, { nullifier, voterId: uid, provider: "rarimo", proofType: "rarimo-passport", disclosures }).mintPohRoot(functions/lib/poh-root.js:30) writesidentity_proofs/{nullifier}(functions/lib/poh-root.js:23,51-62) with:provider: "rarimo",proofType: "rarimo-passport"(as passed in by the caller — the field is provider-agnostic by design, see below),trustTier: trustTierFor(proofType)→"high"forrarimo-passport(functions/lib/tier.js:43),commitment: ""— vestigial. The inline comment atfunctions/lib/poh-root.js:53is explicit: this used to be populated by the Semaphore commitment-attach step (attachSemaphoreCommitment, retired), andpohRootAdmitsnever reads it.- The document id is the nullifier itself, and the write uses
.doc(nullifier).create(root)(not.set()) — one passport can only ever anchor one root; a second attempt with the same nullifier returns{ created: false }instead of overwriting.
mintPohRoot's docstring (functions/lib/poh-root.js:11-14) explains the
provider field is deliberately provider-agnostic: "Self (legacy), Rarimo
(current) and a future ZKPassport lane share one collection and one shape.
Legacy documents predate the field and are read as 'self'."
mintPohRoot is the primary mint for the RariMe passport flow, but it is
not the only writer of identity_proofs. mintPohRoot's own docstring
calls it "the ONE place a Proof-of-Humanity root is created," and
functions/anonymous-vote.js:27's comment says "mintPohRoot remains the
sole minter" — but a source-level invariant test,
functions/__tests__/poh-root-invariant.test.js, enumerates every file
permitted to name the identity_proofs collection at all, and its
PERMITTED list documents two more deliberate write sites alongside
lib/poh-root.js ("THE mint"): lib/manual-review.js ("writes
identity_proofs when an admin approves a manual review") and
user-management.js ("adminManualApproveHumanity: deliberate Ring-gated
bypass for stuck verification"). Both are audited escape hatches, not bugs —
see §4 below for what they write and why "sole minter" should be read as
"sole minter of Rarimo-passport roots," not "sole writer of the
collection." The invariant test is the more authoritative source here (it
fails CI if a new write site appears anywhere in functions/); the two
docstrings above are stale on this specific point and a later doc pass
should tighten that wording in source.
2. The access gate
Admission to the app is a single predicate, pohRootAdmits
(functions/lib/poh-gate.js:25-28):
export function pohRootAdmits(proof) {
if (!proof) return false;
return typeof proof.nullifier === "string" && proof.nullifier.length > 0;
}
That is the entire check: does the voter hold an identity_proofs document
with a non-empty nullifier. The module's docstring
(functions/lib/poh-gate.js:1-20) is explicit about what this replaced: "the
old predicate proof.commitment !== '', which required a device-local
Semaphore keypair cryptographically unrelated to the passport that actually
proved humanity." Under the current model, the PoH root itself — created by
mintPohRoot after a real RariMe verification, or written directly by one
of the two audited admin bypass paths described in §1/§4 — is the
credential. A commitment on a legacy or synthetic
document is now ignored, not required, which is what keeps
pre-cutover accounts (and adminManualApproveHumanity / demo-seeded
accounts) admitted.
The same predicate is mirrored client-side, byte-for-byte, in
evoting-frontend/src/lib/pohGate.ts:20-23. Its docstring states the
authority relationship correctly: "UI gating is convenience; the server-side
check in createVoterAccount and the per-proposal nullifier claim are the
enforcement."
A related function in the same module, pohBindingHashHex
(functions/lib/poh-gate.js:40-51), derives the 32-byte value written as
biometric_hash on the on-chain voter account. With Semaphore retired there
is no second secret to bind, so — for accounts created after the cutover —
this hash is derived from the nullifier under a fixed domain-separation
string; pre-cutover accounts with a non-empty legacy commitment keep that
exact value so the Firestore record and the on-chain PDA don't disagree.
3. Anonymous voting
Per-proposal anonymity is a Rarimo nullifier scoped to that one proposal, not a Semaphore group-membership proof. Two files implement it:
functions/lib/proposal-nullifiers.js— the claim primitive and the vote cast core (castRarimoVoteImpl). Its header (functions/lib/proposal-nullifiers.js:1-16) states this is "the replacement for the Semaphore group-membership vote gate (functions/lib/semaphore.js, retired in Task 8)."proposalEventIdFor(line 47) derives a per-proposal Rarimo event ID by hashing the deployment's base event ID together with the proposal id: "give each proposal its own eventID and the same passport produces a different, unlinkable nullifier per proposal — one vote per human per proposal, with no cross-proposal correlation and no link back to the registration nullifier." The claim itself (claimProposalNullifierTx, line 96) writes only{ votedAt }underproposal_nullifiers/{proposalId}/nullifiers/{nullifier}via a Firestore transaction.create()— create-only, so a repeat claim on the same nullifier fails rather than overwriting.optionIdis a required argument to the claim but is deliberately not persisted (fixed 2026-09-04, seedocs/poh-threat-model.mdT4): this doc is world-readable and the nullifier is voter-reproducible, so storing the chosen option under it would let a voter prove their own vote to a buyer. The public per-option tally comes fromproposals/{id}.options.*.votesinstead, incremented server-side bycastRarimoVoteImpl. The module's docstring is explicit that this is a tombstone, never a delete: "A claimed nullifier stays claimed forever. If a claim could be released, the attack is obvious — vote, release, vote again."functions/anonymous-vote.js— the two callables that use it.startAnonymousVoteProof(authenticated callable, line 181) checks eligibility — a PoH root exists, its tenant matches the proposal's tenant, the voter's trust tier meets the proposal'sminTier, the voter isn't abuse-flagged — then mints a randomsessionIdand requests a RariMe proof scoped to that proposal's event id. Critically, per the docstring atfunctions/anonymous-vote.js:12-17, the session document persists only{ proposalId, eventId, status, createdAt }— no uid — "so nothing stored links the caller to the nullifier they will later claim."castAnonymousVote(unauthenticatedonRequest, line 216) is unauthenticated on purpose: "an auth token here would re-link the voter to the vote." It polls the session's proof status, claims the nullifier viacastRarimoVoteImpl, increments the proposal's vote counters, and burns the session (marks itused, never deletes it) so the same proof request can't be replayed against a second option.
Firestore rules confirm the old Semaphore vote gate is fully closed, not
merely unused: firestore.rules:333-347 reads "anonymous_votes and
semaphore_groups were the Semaphore vote gate. Retired 2026-08-31;
superseded by proposal_nullifiers above. Existing documents are RETAINED
… Reads are closed because nothing reads them any more; nothing may write
them either" — followed by match /semaphore_groups/{groupId} { allow read, write: if false; }.
Note — public polls are a related but distinct flow, not the same
anonymity model. functions/polls.js implements createPoll,
startPollVerification, getPollVerificationStatus and castPollVote
using the same session-id-indirection technique (a fresh random session id
sent to verificator-svc, never the caller's real uid) to keep the
poll-scoped nullifier from colliding with a member's founders-registration
nullifier. But per the file's own header comment
(functions/polls.js:68-77), polls are an authenticated flow that
already stores voter_uid/creator_uid directly on public Firestore docs
(poll_votes, polls) — "there is no voter-anonymity property to preserve
here, only correct scoping." Do not describe poll voting as anonymous in the
same sense as proposal voting; the session-id trick here defends against
wrong-scope nullifier reuse, not against voter identification.
4. Trust tiers
The tier ladder from the retired era is retained as-is. functions/lib/tier.js
defines TIER_RANK = { low: 1, medium: 2, high: 3 } (line 34) — now, per its
own docstring, "the only copy," after Task 8 deleted the duplicate that used
to live in lib/semaphore.js and the inline literal inside the retired
attachSemaphoreCommitment. trustTierFor(proofType) (line 42) maps:
"high"—rarimo-passport(the current live path), plus the legacy"self-passport"value (kept so oldidentity_proofsdocuments from the retired Self Protocol path still resolve to the correct tier) and"mdl-iso18013"(mobile driver's license, NFC+ZK, same trust class)."medium"— legacy"self-id-card"and"ocr-document"."low"—"manual-review", and the fallback for anything unrecognized (including anyproofTypestringtrustTierFordoesn't know about).
trustTierFor is what mintPohRoot calls to stamp trustTier on the
documents it writes, and it's what startAnonymousVoteProofImpl compares
against a proposal's minTier (functions/anonymous-vote.js:138-143) to
gate who may request a vote proof at all. It is not, however, the only
way a trustTier value ends up in identity_proofs — the two bypass
writers named in §1 set it directly, and one of them disagrees with what
trustTierFor would compute:
lib/manual-review.js'sapproveManualReviewImpl(functions/lib/manual-review.js:38) writesproofType: "manual-review"(line 91) withtrustTier: "low"as a literal (line 95). This happens to match whattrustTierFor("manual-review")would return, but the value is not derived by calling it.user-management.js'sadminManualApproveHumanity(functions/user-management.js:433), a Ring-gated (Ring.TENANT_ADMIN) bypass for stuck verification, writesproofType: "admin-manual"(line- with
trustTier: "high"as a literal (line 487). This does not match whattrustTierForwould compute:trustTierForhas no"admin-manual"branch, so if this document's tier were ever derived by callingtrustTierFor(proofType)instead of being hardcoded, it would fall through to the"low"default — the opposite of what is actually written. Do not assumetrustTierForis a complete description of what trust tier anidentity_proofsdocument can carry; read the write site.
- with
5. Attestations
Five on-chain attestation types are defined in
functions/lib/attestation-types.js:20-24:
export const ATTESTATION_VERIFIED_HUMAN = 0;
export const ATTESTATION_VOTED = 1;
export const ATTESTATION_SUPPORTED_PROPOSAL = 2;
export const ATTESTATION_RECEIVED_SHARE = 3;
export const ATTESTATION_POLL_VOTED = 4;
functions/lib/attestation-onchain.js re-exports all five (lines 7-16, "for
backward compatibility — the constants themselves live in
lib/attestation-types.js") and derives each attestation's PDA from
(holderWallet, attestationType, contextHash) (deriveAttestationPDA,
line 46). Issuance sites confirmed in this worktree:
ATTESTATION_VERIFIED_HUMAN at functions/lib/voter-account.js:128,
ATTESTATION_RECEIVED_SHARE and ATTESTATION_POLL_VOTED wired through the
chain adapter at functions/lib/chain/solana-adapter.js:25-35, with
ATTESTATION_POLL_VOTED issued (best-effort, non-fatal) after a successful
poll vote mirrors on-chain (functions/on-chain-tasks.js:514).
Separately, identity_claims/{walletAddress} records selective-disclosure
identity facts on-chain and in Firestore — wallet-keyed, not uid-keyed.
functions/lib/attestation-onchain.js:21,23,159-169 defines the claim shape:
citizenship (3-letter code), sex (via SEX_CODE = { M: 1, F: 2, O: 3 }),
an age-at-least-N-years threshold, and documentNotExpired. The record is
queried by queryIdentityClaims (functions/attestations.js:280-300, authed,
requires verified-member status), which returns only
citizenship/sex/etc. from the stored claims object — never a full
document. functions/on-chain-tasks.js:1027-1045 writes the Firestore side
of the record; functions/account-deletion.js:217,238 documents why the
wallet-keying matters for erasure: deleteMyAccount resolves the member's
wallet address before deleteUserWallet() wipes user_wallets/{uid}
(functions/account-deletion.js:267-274), "identity_claims docs are keyed
by wallet address, not uid, and this is the only place that mapping is ever
recorded" — losing that resolution first would leave the identity_claims
record behind with no path back to the deleted account.
6. What is retired and must not be described as live
All four callables the retired flows depended on are confirmed absent
from functions/*.js and functions/lib/*.js in this worktree (zero
export const/export function/export async function matches for each):
verifyPassportProof— the Self Protocol passport-verification callable.attachSemaphoreCommitment— the Semaphore commitment-attach step that used to populateidentity_proofs.commitment.anchorCommitmentandanchorIdentityCommitmentTask— the device-attestation "humanity seal" path and its backfill task.
Also retired: the lib/semaphore.js module itself (file absent from
functions/lib/ in this worktree), and the Firestore collections
anonymous_votes and semaphore_groups, both now closed to all reads and
writes (firestore.rules:333-347, quoted in full above).
Legacy data is retained deliberately — this is not a claim that these
systems left no trace. Pre-cutover identity_proofs documents with
provider unset (read as "self"), proofType: "self-passport" /
"self-id-card", and non-empty legacy commitment values continue to
resolve correctly through pohRootAdmits, trustTierFor, and
pohBindingHashHex — by explicit design in each of those functions, not by
accident. docs/legacy-identity-data-inventory.md is the canonical record
of what legacy data exists and why it's kept; this document does not
duplicate it.
Positioning note (for downstream partner-facing docs)
Per consolidation spec docs/superpowers/specs/2026-08-30-foundation-rarimo-consolidation-design.md
§6: identity verification runs on "the same Halborn-audited ZK-passport
circuits used in Russia2024 and Iranians Vote." Foundation does not run,
fork, or resell Freedom Tool. Rarimo should be framed as "open-source
cryptography Foundation audits and self-hosts" — not as a vendor Foundation
depends on operationally. infra/verificator/README.md backs this up
operationally: this fork runs its own isolated Cloud Run instance of
verificator-svc (upstream github.com/rarimo/verificator-svc, MIT
license, pinned to a specific commit), not a Rarimo-operated service. This
document does not itself carry partner-facing copy — see
docs/foundation-whitepaper.md and docs/foundation-one-pager-gaas.md
(Tasks 6–7 of this plan) for the sentences downstream readers actually see.