Backend Consolidation & PoH Tier Hardening — Design & Implementation Plan
Date: 2026-04-18 Status: Draft — awaiting approval on open decisions (§8) Supersedes in scope: the PoH tier design (phases B–F of that plan) — folded into §6 below. Does not supersede: PoH Phase A (honest tier derivation) remains independent and ships first.
1. Executive Summary
Decision: Retire evoting-rocket-server/ (Rust/Rocket REST gateway, ~22k LOC). Reimplement its endpoints as Firebase Cloud Functions in Node 22 ESM. Fold the PoH tier hardening work on top of the consolidated stack.
Why now:
- Pre-production system, no real users → clean cutover is safe.
- Four of seven PoH plan findings are symptoms of the dual-auth architecture (Rocket HS256 + Firebase ID tokens). Migration is the root-cause fix, not a parallel refactor.
- Shared
@plantagoai/*packages (auth, messaging, db, flows, testing) are already used byfunctions/— extending them to cover what Rocket does today requires no new infrastructure. - Onboarding pipeline collapses from 8 stages across two auth systems to a single-identity flow.
Scope boundaries:
- Stays:
evoting-backend/programs/fresh-evoting/— the Anchor on-chain program. Lives on Solana devnet. Nothing to port. - Moves:
evoting-rocket-server/— REST gateway, 19 endpoints, stateless, no Rust-only crypto. - Already on Functions: PoH verification (
verifyPassportProof), Semaphore commitment attachment, anonymous vote verification, account deletion, legal consent, AI evaluation, messaging.
Success criteria:
- Zero references to Rocket/evoting-rocket-server in frontend or deploy scripts.
- Single auth layer: Firebase Auth ID tokens flow through
@plantagoai/authrequireRing()middleware to every backend endpoint. verifyPassportProofderivestrustTierfromattestationId(not hardcoded).approveManualReviewgated by anadminring, not "any valid JWT."- Rate limiting on
/access/request-coderestored (App Check + Firestore counter). - Semaphore group build is O(N) amortized, not O(N²) per vote.
evoting-rocket-server/directory deleted from repo;CLAUDE.mdtech stack updated.
2. Current State
2.1 Components
Frontend (React/Vite)
│
├── HTTP → Rocket gateway (Rust, :8000) [TO RETIRE]
│ │
│ ├── builds/signs/submits Anchor tx → Solana devnet RPC
│ ├── HS256 JWT auth (Rocket-issued)
│ ├── proxies PoH calls → Firebase Functions
│ └── writes Firestore directly (optional sync)
│
├── HTTPS → Firebase Cloud Functions (Node 22) [EXPAND]
│ │
│ ├── verifyPassportProof, attachSemaphoreCommitment
│ ├── getSemaphoreGroup, verifyAnonymousVote
│ ├── approveManualReview, sendMail, sendNotification
│ ├── evaluateProposal (Claude), lookupPopulation
│ ├── deleteMyAccount, exportMyData, etc.
│ └── getPrivacyPolicy, getTermsOfService, etc.
│
└── Firebase Auth ID tokens [BECOMES SOLE AUTH]
2.2 Dual-auth failure modes inherited from this architecture
| Failure | Root cause | PoH plan finding |
|---|---|---|
attachSemaphoreCommitment silently 401s |
Rocket JWT_SECRET (main.rs:154 with ephemeral fallback) ≠ Functions JWT_SECRET param |
#4 |
| Account deletion unreachable for Foundation users | CFs use requireAuth (Firebase ID token); users have Rocket HS256 |
#6 |
"Admin = any valid JWT" for approveManualReview |
functions/index.js:1091 stub — no role check because no role source |
#3 |
verifyPassportProof hardcodes trustTier: "high" |
functions/index.js:787 — receives attestationId (line 717) but ignores it |
#1 |
| Base64 photos in Firestore for manual review | ManualReviewEnrollment.tsx:56-70 — cost + GDPR |
#5 |
| Chip-fail selfie path | Self app UI only; no proof reaches Foundation → not integrable | #2 |
| Semaphore group rebuilt per-vote | functions/index.js:1031 — O(N) Firestore read + O(N²) Merkle per vote |
#7 |
Observation: findings #3, #4, #6 are dual-auth symptoms. Findings #1, #5, #7 are pure Functions bugs — migration-independent. Finding #2 is a Self-app constraint — design choice, not code.
2.3 Rocket endpoint inventory (to be retired)
From evoting-rocket-server/ audit:
| Route class | Endpoints | Disposition |
|---|---|---|
| Health/debug | /, /debug, /api/v1/logs |
Drop (Firebase has its own) |
| Auth | /api/v1/auth/{register,challenge,login,refresh} |
Delete — replaced by Firebase Auth |
| Access | /api/v1/access/{request-code,verify-code,accept-tos} |
Port + switch to Firebase custom tokens OR replace with Firebase email-link (open decision §8.3) |
| PoH | /api/v1/poh/{verify-passport,attach-commitment,anonymous-vote,status,api-keys} |
Delete — frontend already can call Functions directly; collapse the proxy |
| Voting | /populations, /voters, /proposals, /supports, /votes |
Port: build + sign + submit Anchor instructions via @solana/web3.js |
3. Target State
3.1 Components
Frontend (React/Vite)
│
├── Firebase Auth (email-link or email+code, §8.3)
│ │
│ └── ID token attached to every request
│
├── Cloud Functions (callable, where auth required)
│ │
│ ├── createProposal, castVote, createVoterAccount, submitSupport
│ │ └── @solana/web3.js + @coral-xyz/anchor client → Solana RPC
│ ├── verifyPassportProof (fixed tier derivation)
│ ├── attachSemaphoreCommitment (no more JWT mismatch)
│ ├── requestAccessCode / verifyAccessCode (if we keep access-code flow)
│ ├── approveManualReview (requireRing('admin'))
│ └── ... existing Functions ...
│
├── Cloud Functions (HTTPS, public or special-cased)
│ ├── getSemaphoreGroup (publicly readable)
│ ├── getPrivacyPolicy, getTermsOfService
│ └── Self SDK webhook targets
│
├── App Check (reCAPTCHA Enterprise / DeviceCheck)
│ └── attached to callable Functions → non-browser traffic rejected
│
└── Firestore rate counters (access_code_requests/{emailHash|ipHash})
└── enforced inside requestAccessCode
3.2 Auth flow (after consolidation)
- User provides email → Firebase Auth sends sign-in link (or 6-digit code via
@plantagoai/messaging). - User verifies → Firebase Auth issues ID token + creates
users/{uid}doc (trigger). - Every subsequent backend call carries the ID token;
@plantagoai/authmiddleware resolves rings, custom claims. - PoH, Semaphore, voting — all run off the same identity. No second JWT to reconcile.
3.3 Tier enforcement (folded from PoH plan §C–§D)
Unchanged from the PoH plan except that it now runs against a single auth layer:
| Action | Min tier | Enforcement point |
|---|---|---|
| Sign in / view | none | Firebase Auth only |
| Support a proposal | LOW | requireRing('verified-low') or tier check in handler |
| Vote anonymously | MID | tier-filtered Semaphore group (semaphore_groups/mid or /high) |
| Create proposal | HI | requireRing('verified-high') |
Tier derivation table (unchanged from PoH plan):
| Tier | Evidence | Self attestationId |
proofType |
|---|---|---|---|
| HI | NFC+ZK passport | 1 |
self-passport |
| MID | NFC+ZK biometric ID card | 2, 4 |
self-id-card |
| LOW | Human-reviewed photo+selfie | — | manual-review |
4. Guiding Principles
- No time estimates. Ordered phases only. Each phase has entry/exit criteria.
- Shared-package-first. Use
@plantagoai/auth,@plantagoai/messaging,@plantagoai/db,@plantagoai/flows,@plantagoai/testing,@plantagoai/firebase-corewherever applicable. Flag any place a new shared primitive is warranted. - No dual-running. Clean cutover at the appropriate phase boundary. No traffic-shifting middleware.
- No backwards-compat shims for Rocket. When an endpoint moves, Rocket's copy is deleted, not deprecated.
- Vertical slices. Each phase must be independently deployable and leave the app in a working state.
- Keep the on-chain program untouched. No Anchor program changes are part of this plan. If one becomes necessary, it's a separate decision.
5. Phased Plan — Migration Core
Phase 0 — PoH Phase A (honest tier derivation) — starts independently, can ship before Phase 1
Zero migration dependency. Ships the 3-line fix immediately.
Tasks:
functions/index.js:787: replacetrustTier: trustTierFor("self-passport")with a derived value fromattestationId(already extracted at line 717).- Extend
trustTierFor()(line 663) to acceptself-id-cardand map attestation IDs → proofType. - Widen
IdentityProoftype in the frontend to includeself-id-card. - Persist
attestationIdinto theidentity_proofs/{uid}doc for audit. - Unit tests via
@plantagoai/testingcovering: passport → HI, IL/PT card → MID, manual review → LOW.
Exit criteria:
- IL/PT Teudat Zehut / Cartão de Cidadão users land at MID, not HI.
- Existing passport users still land at HI (no regression).
identity_proofsdocs carryattestationIdfor new writes.- Pre-existing docs grandfathered as HI (acceptable — pilot volume is small; <5% potential misclassification is below noise).
Phase 1 — Migration foundation
Scaffolding only. No endpoints move yet. Leaves Rocket fully functional.
Tasks:
- Secret Manager: move the Solana signing keypair out of Rocket env/disk into GCP Secret Manager. Grant the Cloud Functions service account
roles/secretmanager.secretAccessor. - Solana client module in
functions/lib/solana.js:- Loads keypair from Secret Manager (cached per instance).
- Exposes
getConnection(),getProgram()using@solana/web3.js+@coral-xyz/anchor. - Reads IDL from
functions/idl/fresh_evoting.json(copied fromevoting-backend/target/idl/).
- IDL sync script:
scripts/sync-idl.shcopies IDL + type definitions intofunctions/idl/after everyanchor build. Fail loudly on drift. - Auth helper extension: confirm
@plantagoai/authrequireRing()works with onCall functions. If gaps, add shared helper rather than one-off wrappers. - App Check setup: enable App Check on the Firebase project (reCAPTCHA Enterprise for web). Do not enforce yet — monitor only.
Exit criteria:
- A smoke-test onCall function can load the keypair, build an Anchor transaction (dry-run, no submit), and return success.
- IDL sync script documented in
CLAUDE.md. - App Check metrics visible in Firebase console.
Risks:
- Secret Manager permission misconfig → Functions can't load the keypair. Mitigation: smoke test as exit criterion.
- IDL drift between Anchor program and Functions client. Mitigation: sync script + CI check in a follow-on phase.
Phase 2 — Port Solana core endpoints
Vertical slice: one endpoint end-to-end, frontend switched, Rocket still up for others. Repeat per endpoint.
Order:
castVote(highest-value, simplest instruction signature)createProposalcreateVoterAccountsubmitSupport
Per-endpoint template (onCall):
- Input validation (zod schema) — matches current Rocket route's body shape where sensible.
requireAuth()→ resolve uid.- (Later, tier gate:
requireRing('verified-high')for createProposal etc. — wired in Phase 7.) - Build instruction via Anchor TS client + IDL.
- Sign with program keypair from Phase 1's
getProgram(). - Submit to devnet RPC.
- Write mirror doc to Firestore via
tenantCreate()from@plantagoai/db. - Return
{ signature, explorerUrl, ... }.
Frontend changes per endpoint:
- Replace Rocket fetch call with
httpsCallable(getFunctions(), 'castVote'). - Remove the matching Rocket route from the client base URL config.
Read endpoints:
GET /proposals,GET /votes, etc. — not ported to Functions. Frontend queries Firestore directly withtenantQuery()from@plantagoai/db. Subject to §8.2 confirmation.
Cold-start mitigation:
- Set
minInstances: 1oncastVoteandcreateProposal. Others can stay at 0.
Exit criteria:
- All four onCall endpoints live and exercised by the frontend in dev.
- E2E test (via
@plantagoai/e2e) covers create-proposal → cast-vote round-trip hitting Functions, not Rocket. - Corresponding Rocket routes unused (confirm via Rocket access log).
Phase 3 — Collapse the PoH proxy
The PoH routes in Rocket (evoting-rocket-server/src/poh.rs) already HTTP-call Firebase Functions (see call_cloud_function() at poh.rs:136). The proxy adds latency and a second JWT hop for zero value.
Tasks:
- Frontend: replace calls to
/api/v1/poh/verify-passportetc. with direct Functions calls (verifyPassportProofonRequest or convert to onCall). - Consider converting
verifyPassportProoffromonRequesttoonCallfor consistency + free App Check + free auth context. Caveat: the Self SDK callback may require a public HTTPS endpoint — keep onRequest for the Self callback path if so, but switch frontend-initiated calls to onCall. - Delete
evoting-rocket-server/src/poh.rsand its route mounting.
Exit criteria:
- No frontend code references
/api/v1/poh/*. - Rocket no longer exposes PoH routes.
Phase 4 — Auth cutover (the dual-auth fix)
Retire Rocket's JWT issuance entirely. Single auth layer = Firebase Auth.
Tasks:
- Frontend: remove Rocket register/challenge/login/refresh code paths. Replace with Firebase Auth sign-in (§8.3 decides email-link vs email-code).
attachSemaphoreCommitment(functions/index.js:833): switch from custom HS256 JWT verification to Firebase Auth ID token viarequireAuth(). Kill theJWT_SECRETparam.approveManualReview(functions/index.js:1091): replace "any JWT = admin" withrequireRing('admin')from@plantagoai/auth. Source admin list fromadmin_allowlistcollection or custom claims (§8.4).- Account deletion: no code change — now reachable by construction because users have Firebase Auth tokens.
Exit criteria:
/api/v1/auth/*Rocket routes deleted.attachSemaphoreCommitmentend-to-end test: user signs in with Firebase, generates Semaphore commitment, attaches, group membership confirmed.approveManualReviewtest: non-admin Firebase user gets 403; admin-ringed user succeeds.- PoH findings #3, #4, #6 marked resolved.
Risks:
- Existing sessions break. Acceptable per user: "re-login is a lesser problem."
- If admin ring has no members yet, manual reviews are blocked. Mitigation: seed one admin (Dagan's uid) before deploy.
Phase 5 — Access code flow on Functions + Resend
The access-code path (/api/v1/access/request-code, /verify-code) is the last Rocket endpoint class with user-facing value.
Decision point (§8.3): do we keep the 6-digit access-code pattern, or switch to Firebase Auth's built-in email-link?
Assuming we keep access codes (aligns with backlog note "migrate access code to Resend"):
Tasks:
- Port to Cloud Functions:
requestAccessCode(onRequest, rate-limited — see Phase 6),verifyAccessCode(onCall or onRequest → mints Firebase custom token). - Email delivery via
@plantagoai/messagingusing Resend. - Store codes hashed in
access_codes/{codeHash}with TTL, consumed-on-use. acceptTosalready exists asrecordTosAcceptanceinfunctions/legal.js— drop the Rocket route, point frontend to existing Function.
Exit criteria:
- Frontend access-gate flow uses Functions + Resend end-to-end.
/api/v1/access/*Rocket routes deleted.
Phase 6 — Rate limiting rebuild (App Check + Firestore counters)
Replaces the Rocket rate-limiter lost in Phases 3–5. Required before Phase 7 exposes more surface.
Layer 1: App Check (cheap, broad, free)
- Enforce App Check on all callable functions and the PoH
onRequestendpoints. - reCAPTCHA Enterprise for web traffic, debug tokens for E2E tests.
- Blocks non-browser automation and curl-from-script abuse without per-endpoint code.
Layer 2: Per-email / per-IP counter (targeted, abuse-resistant)
For requestAccessCode specifically:
// functions/access-code.js (pseudocode)
const counterRef = db.doc(`access_code_requests/${emailHash}`);
await db.runTransaction(async tx => {
const snap = await tx.get(counterRef);
const now = Date.now();
const hourAgo = now - 3600_000;
const recent = (snap.data()?.timestamps || []).filter(t => t > hourAgo);
if (recent.length >= 5) throw new HttpsError('resource-exhausted', 'Too many requests');
tx.set(counterRef, { timestamps: [...recent, now] }, { merge: true });
});
Limits (initial):
- 5 requests per email per hour.
- 20 requests per IP per hour (secondary check —
emailHashvsipHash). - Exponential backoff on repeated failures (optional).
Firestore schema addition:
access_code_requests/{emailHash}
timestamps: number[] // epoch ms, trimmed to last hour
blockedUntil?: number // optional hard-block
TTL policy on the collection: auto-delete docs after 24h of inactivity.
Exit criteria:
- App Check enforced (not just monitored) on all callable/onRequest Functions.
requestAccessCoderejects 6th request within an hour with a clear error.- E2E tests use App Check debug tokens (no production tokens in CI).
Risks:
- App Check false positives on certain mobile webviews. Mitigation: monitoring period in Phase 1 before enforcement.
- Firestore contention on hot emails. Mitigation: hash-shard the counter key if ever needed (likely never at this volume).
6. Phased Plan — PoH Tier Hardening (post-migration)
Phase 7 — Tier-filtered Semaphore groups + identity gate
Carries forward PoH plan Phase C unchanged in design, but now runs on the unified auth stack.
Tasks:
semaphore_groups/{tier}Firestore doc containing the Merkle root + serialized tree for that tier (low,mid,high).rebuildSemaphoreGroupstrigger: on anyidentity_proofs/{uid}write, recompute the group(s) the user qualifies for and update the cached doc.- Fixes PoH finding #7 (O(N²) per-vote rebuild): groups are rebuilt on identity-proof writes (rare), read from cache on every vote (O(1) lookup, O(log N) proof verification).
- Frontend
identityGate.ts: helper that maps a user's tier → allowed actions. proposalsschema: addminTierfield ('low' | 'mid' | 'high', default'mid').
Exit criteria:
- Group rebuild triggered by identity-proof write, verified by checking updated root.
- A MID user can vote on a MID-minimum proposal, rejected on a HI-minimum proposal.
- PoH finding #7 resolved.
Phase 8 — Onboarding UX rebuild
Carries forward PoH plan Phase D. Simplified because auth is unified.
Tasks:
- Collapse the 8-stage pipeline into a flow defined via
@plantagoai/flowsdefineFlow(). - Stages: email → Firebase Auth sign-in → ToS → passport capture (Self app) → PoH verification → Semaphore commitment.
AccessGateroutes unproofed users to the Register flow.TierBadgecomponent on the user profile.- IL/PT copy: "Try a different method" guidance for TD1/no-MRZ cases (per memory
project_mrz_scanner_gap.md). - Unified progress bar with a fixed stage count (no more branching UX).
Exit criteria:
- New user in dev goes from zero → Semaphore-committed with one cohesive flow.
- E2E test covers the happy path via
flowTest()from@plantagoai/e2e.
Phase 9 — Security hardening
Carries forward PoH plan Phase E.
Tasks:
- Photos → Storage:
ManualReviewEnrollment.tsx:56-70writes base64 photos into Firestore docs. Replace with upload togs://.../manual-review/{uid}/{timestamp}.jpgvia Firebase Storage SDK, store signed URL ref in Firestore. - Admin allowlist:
admin_allowlist/{uid}collection read byrequireRing('admin'). Alternative: Firebase Auth custom claims set via admin-setter function. §8.4 decides. - Deletion cleanup: ensure
deleteMyAccount(via@plantagoai/auth/deleteAccount) covers Storage photos + semaphore group membership recomputation.
Exit criteria:
- No base64 photos in Firestore docs (check via query).
- Admin list queryable, audit-loggable.
- Account deletion removes all user-owned Storage objects.
Phase 10 — Reconciliation (scheduled function OR skip)
The Rocket ReconciliationService is a no-op placeholder (reconciliation.rs:107). Two choices:
Option A (skip): delete entirely. The system has no drift source today — every write goes through a Function that writes Firestore + submits tx in one handler.
Option B (implement): onSchedule('every 15 minutes') Cloud Function that:
- Reads recent on-chain vote/proposal accounts via
getProgramAccounts. - Compares against Firestore mirror.
- Writes drift-repair updates.
Recommendation: Option A now, revisit if drift is ever observed. Less code = fewer bugs.
Exit criteria (if Option A): Rocket's reconciliation.rs module deleted. Nothing added to Functions.
Phase 11 — Cutover & cleanup (folds in PoH plan Phase F)
Final sweep.
Tasks:
- Confirm no code in
evoting-frontend/references Rocket. Grep for the Rocket base URL env var. - Delete
evoting-rocket-server/directory. - Update
CLAUDE.md:- Tech stack: remove "Rocket".
- Project Structure: remove
evoting-rocket-server/. - Add line under
evoting-backend/: "Anchor on-chain program only — no server."
- Update
docs/architecture_components-2026-04-16_01-30.mdto reflect consolidated backend. - Update
scripts/deploy-functions.shas needed; remove any Rocket deploy scripts. - Delete Rocket-related env vars from config files.
- Flag migration (
VITE_POH_TIER_GATES_ENABLED) removal: once staged rollout is validated, delete the flag.
Exit criteria:
grep -r "rocket"(case-insensitive) in repo returns only historical doc references.- Deploy pipeline has one backend artifact (Functions), not two.
- All seven success criteria (§1) met.
7. Risks & Mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| Cold-start latency on tx-signing endpoints | Medium | minInstances: 1 on castVote, createProposal. Measure p99 after Phase 2. |
| Anchor IDL drift between on-chain program and Functions TS client | High | IDL sync script (Phase 1). Fail loudly on missing file. CI check that functions/idl/*.json matches evoting-backend/target/idl/*.json. |
| Secret Manager misconfig locks Functions out of signing | High | Phase 1 exit criterion is an end-to-end smoke test that loads the key. |
| App Check false positives block legitimate users | Medium | Monitor-only mode in Phase 1; enforce only in Phase 6 after data collection. |
| Firebase Auth session cost (new sign-in for every existing dev user) | Low | Accepted — no real users, per user confirmation. |
| Frontend regressions during Phase 2 per-endpoint porting | Medium | One endpoint at a time; E2E test before moving to next. |
| Admin ring empty at cutover → manual reviews blocked | Low | Seed Dagan's uid as admin in a migration script before Phase 4 deploy. |
| Functions execution quota / cost blow-up from App Check reCAPTCHA | Low | reCAPTCHA Enterprise free tier covers likely dev + early-pilot volume. Monitor billing alerts. |
Grandfathered pre-migration identity_proofs docs misclassified as HI |
Low | Accepted per PoH plan: pilot volume <5% misclassification is below noise. Spot-check after Phase 0. |
8. Decisions (resolved 2026-04-18)
All six decisions accepted per recommendation. Summary:
| # | Decision | Resolution |
|---|---|---|
| 8.1 | Callable vs HTTPS | Callable default for auth'd endpoints; HTTPS only for third-party webhooks (Self SDK callback) and public reads (getSemaphoreGroup). |
| 8.2 | Read endpoints | Direct Firestore from frontend via tenantQuery() + security rules. Functions reserved for writes + tx submission. |
| 8.3 | Access code mechanism | Keep 6-digit code, port to Functions + Resend via @plantagoai/messaging (Phase 5). |
| 8.4 | Admin role source | admin_allowlist/{uid} Firestore collection. Cache in Function instance memory if read cost matters later. |
| 8.5 | Reconciliation | Skip — delete Rocket's no-op placeholder, no replacement. Revisit only if drift observed. |
| 8.6 | Chip-fail selfie | Do not integrate. Chip-fail users routed to manual review only. |
Detail (retained for context)
8.1 Callable vs HTTPS functions
Recommendation: callable by default for anything that needs auth. HTTPS for public webhooks (Self SDK callback, getSemaphoreGroup for anonymous read).
Why: callables auto-attach the Firebase Auth ID token, integrate natively with App Check, give cleaner client code (httpsCallable('castVote')(args)), and produce typed request/response with better error shapes.
When HTTPS is correct: third-party callbacks where you can't control the caller.
8.2 Read endpoints
Options:
- (a) Port to Functions (
getProposals,getVotes, etc.). - (b) Frontend queries Firestore directly via
tenantQuery()from@plantagoai/db, with Firestore security rules enforcing tenant scoping.
Recommendation: (b). It's cheaper (no Function invocation per read), faster (no cold starts), and the tenant-scoping + security rules are already proven in other Foundation flows. Functions stay focused on writes + tx submission.
Caveat: list endpoints with complex filtering may eventually justify a Function. Cross that bridge only if query complexity demands it.
8.3 Access code vs Firebase Auth email-link
Options:
- (a) Keep 6-digit access code, port to Functions + Resend (Phase 5 as drafted).
- (b) Switch to Firebase Auth's built-in email-link sign-in. Firebase handles the send, link, and verification.
Tradeoffs:
- (a) preserves existing UX (users already expect a code); one more Function to maintain; rate limiting fully in our hands.
- (b) zero backend code for access flow; weaker UX for mobile users (deep-link back into app can be flaky); less control over email template unless we customize via Firebase's sendEmailVerification template.
Recommendation: (a) — consistent with backlog "migrate access code to Resend," matches current UX, and @plantagoai/messaging + Resend is already built.
8.4 Admin allowlist vs Firebase custom claims
Options:
- (a)
admin_allowlist/{uid}Firestore collection, read on each check. - (b) Firebase Auth custom claims (
admin: true) set via an admin-setter Function.
Tradeoffs:
- (a) easy to list/audit, no token refresh needed on role change, 1 Firestore read per admin action.
- (b) zero Firestore read per check (claim travels in the ID token), but claim changes require token refresh, and bootstrapping the first admin is awkward.
Recommendation: (a) for simplicity + auditability. Cache in Function instance memory if the read cost ever matters.
8.5 Reconciliation
Covered in Phase 10. Recommendation: skip (Option A).
8.6 Confirm: chip-fail selfie stance
PoH plan recommends not integrating the Self-app chip-fail selfie path (Self-app-internal UI with no proof posted to Foundation). Chip-fail users → manual review only. Confirm before Phase 7.
9. Artifacts & Key References
9.1 File references (from code audit)
functions/index.js:663—trustTierFor()(Phase 0)functions/index.js:717—attestationIdextracted but unused for tier (Phase 0)functions/index.js:787— hardcoded HI tier (Phase 0)functions/index.js:833—attachSemaphoreCommitmentwith custom JWT (Phase 4)functions/index.js:1031— per-vote Semaphore group rebuild (Phase 7)functions/index.js:1091—approveManualReview"any JWT" stub (Phase 4)evoting-rocket-server/src/main.rs:154— RocketJWT_SECRETephemeral fallback (deleted in Phase 4)evoting-rocket-server/src/poh.rs:136— Rocket'scall_cloud_function()proxy (deleted in Phase 3)evoting-rocket-server/src/reconciliation.rs:107— no-op reconciliation (Phase 10)evoting-frontend/src/components/ManualReviewEnrollment.tsx:56-70— base64 photos to move (Phase 9)
9.2 Related docs (existing)
docs/foundation_poh-2026-04-11_00-46.md— PoH architecture baselinedocs/poh-phase0-audit.md— earlier PoH auditdocs/poh-api-guide.md/docs/poh-api-openapi.yaml— current PoH API shapedocs/auth-architecture-design.md— ring-based auth via@plantagoai/authdocs/architecture_components-2026-04-16_01-30.md— component architecture (needs update in Phase 11)docs/firestore-schema.md— collection list (needs update foraccess_code_requests,semaphore_groups,admin_allowlist)
9.3 Shared packages in scope
@plantagoai/auth—requireAuth,requireRing,deleteAccount,exportUserData@plantagoai/firebase-core— Firebase init@plantagoai/db—tenantQuery,tenantCreate, validators@plantagoai/messaging— email (Resend) + notifications@plantagoai/flows— onboarding state machine (Phase 8)@plantagoai/testing— mocks for unit tests (Phase 0 onwards)@plantagoai/e2e—flowTest()for onboarding E2E (Phase 8)
9.4 Firestore schema additions
access_code_requests/{emailHash} # Phase 6 rate-limit counters
timestamps: number[]
blockedUntil?: number
semaphore_groups/{tier} # Phase 7 tier-filtered group cache
merkleRoot: string
tree: string (serialized)
memberCount: number
updatedAt: timestamp
admin_allowlist/{uid} # Phase 9 admin role source
grantedBy: string (uid)
grantedAt: timestamp
note?: string
identity_proofs/{uid} # Phase 0 — new field
+ attestationId: number // 1=passport, 2/4=id-card
10. Decision Gates (what blocks what)
Phase 0 ─────────────────────────── (independent — ship anytime)
Phase 1 (scaffolding)
│
├─→ Phase 2 (port Solana endpoints)
│ │
│ └─→ Phase 3 (collapse PoH proxy)
│ │
│ └─→ Phase 4 (auth cutover) ◀── THE unification point
│ │
│ └─→ Phase 5 (access code on Functions)
│ │
│ └─→ Phase 6 (App Check + rate limits)
│ │
│ └─→ Phase 7 (tier groups)
│ │
│ └─→ Phase 8 (onboarding UX)
│ │
│ └─→ Phase 9 (hardening)
│ │
│ └─→ Phase 10 (recon — or skip)
│ │
│ └─→ Phase 11 (cleanup)
Phases 0 and 1 can run in parallel. Everything downstream of Phase 1 is strictly sequential by construction.
11. Approval Status
- ✅ Phase ordering (§5 + §6) — approved 2026-04-18.
- ✅ §8 decisions — all six accepted per recommendation 2026-04-18.
- ⏳ Starting point — pending: Phase 0 alone, Phase 0 + Phase 1 in parallel, or other?
- ⏳ Doc location — pending confirmation (current path:
docs/architecture_backend-consolidation-2026-04-18.md).