Firestore Database — Schema & Admin Tools
Complete reference for Foundation's Firestore database: collection schemas, validation rules, referential integrity, admin tools, and data management.
Collection Overview
| Collection | Documents | Tenant-Scoped | Aggregates | Description |
|---|---|---|---|---|
proposals |
Governance proposals (Pillar 1) | Yes | total_votes, options.votes, support_count | Core governance records |
voters |
Registered voters | Yes | — | Voter registry with verification status + populations / h3_cells |
votes |
Individual votes (Pillar 1) | Yes | — | One doc per vote, references proposal + option |
supporter_signatures |
Support signatures (Pillar 1) | Yes | — | Pre-vote support for proposals |
allocations |
Fund-allocation proposals (Pillar 2) | Yes | total_votes, options.votes, support_count | P2 governance records — own collection, shares the proposal doc shape + P2 fields. Options are crowd-sourced (submitAllocationOption). |
allocation_votes |
Individual P2 votes | Yes | — | One doc per allocation vote, references allocation + option |
allocation_signatures |
P2 support signatures | Yes | — | Round0 support for allocations |
product_requests |
Marketplace items (Pillar 3) | Yes | — | Product listings with embedded crowd-sourced supplier bids[] |
product_votes |
Individual P3 votes | Yes | — | One doc per supplier-selection vote, references product + bid |
attestations |
Soulbound attestations | No (per-wallet) | — | On-chain attestation mirror; doc id {wallet}_{type}_{contextHashHex} |
voting_rounds |
Voting round metadata | Yes | — | Round type and status tracking |
funds |
Community funds (Pillar 2, legacy) | Yes | — | UBI fund definitions and balances (pre-allocations) |
distributions |
Fund distributions | Yes | — | Distribution records, references funds |
savings_summary |
Aggregate savings | No | — | Singleton doc (ID: "current") |
identity_proofs |
ID verification records | Yes | — | PoH proof records, references voters |
Collection Schemas
proposals
Written by createProposalDraft (functions/proposal-voting.js). The doc id is a server-generated UUID. options is an object keyed by optionId, not an array — the array shape silently breaks vote recording.
{
title: string, // required, ≤ 200 chars
description: string, // required, ≤ 10_000 chars
category: string,
author: string, // display name
author_wallet: string,
status: "draft" | "round0" | "active" | "closed" | "approved" | "rejected",
geographic_scope: "house" | "street" | "neighborhood" | "city" | "county" | "state" | "country" | "continent" | "global",
location: string,
place_id: string | null,
formatted_address: string | null,
coordinates: { lat: number, lng: number } | null,
address_components: object[] | null,
pillar?: 1 | 2 | 3, // numeric pillar (optional)
support_count: number, // integer >= 0 — AGGREGATE
support_threshold: number, // integer >= 0
total_eligible_voters: number, // integer >= 0 (annotated by lookupPopulation trigger)
total_votes: number, // integer >= 0 — AGGREGATE: actual vote count
options: { // OBJECT keyed by optionId, NOT an array
[optionId: string]: {
id: string,
label: string,
description: string,
votes: number, // integer >= 0 — AGGREGATE: per-option vote count
sort_order: number // maps to on-chain optionIndex
}
},
voting_starts_at: string | null, // ISO 8601, stamped on round0→active
voting_ends_at: string | null, // ISO 8601
voting_duration_hours: number,
tags: string[],
age_min: number | null,
age_max: number | null,
governance_template_id?: string,
governance_config?: { rounds: Array<{ type: "support" | "vote" | "review" | "amendment", durationHours?: number }> },
blockchain_tx_hash: string, // "" until twin mints
on_chain_address?: string, // stamped by mintProposalOnChainTask
created_at: string, // ISO 8601 date
updated_at?: string, // ISO 8601, stamped on admin status changes
proposer_uid: string, // server-trusted creator (Firebase uid)
tenant_id: string // from signed custom claim, never the client
}
Aggregates verified:
total_votesmust equal count ofvotesdocs whereproposal_id == this.idoptions[optionId].votesmust equal count ofvotesdocs whereproposal_id == this.id && option_id == optionIdsupport_countmust equal count ofsupporter_signaturesdocs whereproposal_id == this.id
voters
Written by registerVoterProfile (functions/voter-profile.js). Doc id is voter:<sha256(wallet_address)[:32]> (stable per wallet, idempotent re-register).
{
wallet_address: string, // required
display_name: string,
status: "pending" | "active" | "suspended" | "rejected",
biometric_verified: boolean,
id_verified: boolean,
age: number, // integer >= 0
location: string, // default "Unknown"
geographic_scope: string, // default "global"
votes_count: number,
proposals_count: number,
populations: (number | string)[], // UN M49 population codes; defaults to ["global"].
// setMyNationality / assignVoterPopulations write number[]
// [1, continent, country]; setMyLocation adds h3_cells.
h3_cells?: string[], // opt-in H3 cell ids (decimal u64 strings) from setMyLocation
registered_at: string, // ISO 8601 date
registered_by_uid: string, // server-stamped: Firebase uid that registered the profile
tenant_id: string
}
Note: the
lookupPopulationFirestore trigger fires onproposals/{id}creation (looks uptotal_eligible_votersfrom Wikidata/World Bank), not onvoters.
votes
Written by castProposalVote (functions/proposal-voting.js). Doc id auto-generated. anonymous_hash is derived server-side from the caller's uid (deriveLegacyAnonHash) — the client never supplies it.
{
proposal_id: string, // required — REFERENCE → proposals
option_id: string, // required — must be a key in proposal.options
anonymous_hash: string, // server-derived — for duplicate detection
created_at: string, // ISO 8601 date
on_chain?: object, // stamped by mirrorVoteOnChainTask when the Anchor tx lands
tenant_id: string
}
Integrity checks:
proposal_idmust reference an existing proposaloption_idmust exist as a key in the referenced proposal'soptionsobject- No two votes with the same
anonymous_hashon the same proposal (duplicate detection, enforced in-transaction)
supporter_signatures
Written by castProposalSupport (functions/proposal-voting.js). Allowed only while the proposal is in round0. Crossing support_threshold transitions the proposal (typically → active).
{
proposal_id: string, // required — REFERENCE → proposals
anonymous_hash: string, // server-derived — for duplicate detection
created_at: string, // ISO 8601 date
tenant_id: string
}
Integrity checks:
proposal_idmust reference an existing proposal- No duplicate
anonymous_hashper proposal (enforced in-transaction)
allocations (Pillar 2)
Written by createAllocation (functions/allocations.js). Mirrors the proposals doc shape (same options object-keyed-by-id, same status transitions) plus P2-specific fields. Doc id is a server-generated UUID. Options are crowd-sourced: submitAllocationOption appends member-submitted funding options to options while the allocation is in round0/draft (one per caller, capped at 10, each stamped submitted_by / submitted_at / tenant_id).
{
// ... all fields from `proposals` (title, description, category, author,
// author_wallet, status, geographic_scope, location, place_id,
// coordinates, support_count, support_threshold, total_eligible_voters,
// total_votes, options{}, voting_*, tags, age_*, governance_*,
// proposer_uid, tenant_id, on_chain_address?) ...
status: "draft" | "round0" | "active" | "closed" | "approved" | "rejected",
options: { // OBJECT keyed by optionId
[optionId: string]: {
id: string,
label: string,
description: string,
votes: number,
sort_order: number,
// present on crowd-sourced (submitAllocationOption) options:
amount?: number | null,
submitted_by?: string, // Firebase uid
submitted_at?: string, // ISO 8601
tenant_id?: string
}
},
// ── P2-specific ──
amount: number, // integer >= 0
pool_pct: number, // basis points 0–10000
disbursement_schedule: string // ≤ 32 chars
}
allocation_votes (Pillar 2)
Written by castAllocationVote. Allowed only while the allocation is active. One vote per anonymous_hash per allocation.
{
allocation_id: string, // REFERENCE → allocations
option_id: string, // must be a key in allocation.options
anonymous_hash: string, // server-derived
created_at: string, // ISO 8601 date
tenant_id: string
}
allocation_signatures (Pillar 2)
Written by castAllocationSupport. Allowed only while the allocation is in round0.
{
allocation_id: string, // REFERENCE → allocations
anonymous_hash: string, // server-derived
created_at: string, // ISO 8601 date
tenant_id: string
}
voting_rounds
{
proposal_id: string, // REFERENCE → proposals
round_type: "support" | "voting" | "runoff",
status: "active" | "completed" | "cancelled",
started_at: string,
ended_at: string, // optional
tenant_id: string
}
funds
{
name: string,
description: string,
status: "active" | "paused" | "completed" | "draft",
categories: [
{ name: string, allocation: number }
],
total_balance: number, // >= 0
distributed_amount: number, // >= 0
participant_count: number, // integer >= 0
location: string,
tenant_id: string
}
distributions
{
fundId: string, // REFERENCE → funds
status: "pending" | "processing" | "completed" | "failed",
amount: number,
recipient_count: number,
distributed_at: string,
tenant_id: string
}
product_requests (Pillar 3)
Lifecycle: gathering → bidding → voting → delivered/cancelled. Demand is gathered first; once demandCount >= minimumThreshold, the first supplier bid (submitProductBid) flips gathering → bidding. Bids are crowd-sourced and embedded in the bids[] array (not a sub-collection) — one bid per caller, deduped by supplier name, capped at 10 (the on-chain twin's option limit). adminUpdateProductStatus is the admin status-transition callable.
{
title: string,
description: string,
status: "gathering" | "bidding" | "voting" | "delivered" | "cancelled",
demandCount?: number, // members who registered demand
minimumThreshold?: number, // demand needed before bidding opens
proposalId?: string, // optional — REFERENCE → proposals
on_chain_address?: string, // stamped by mintProductOnChainTask
bids: [ // embedded array, appended by submitProductBid
{
id: string, // "bid-<uuid>" — referenced by product_votes.bid_id
supplierName: string,
pricePerUnit: number, // > 0
retailPrice: number, // > 0
unit: string, // default "unit"
certifications: string[], // ≤ 6
deliveryDays: number, // default 7
rating: number, // default 0
totalReviews: number, // default 0
sampleAvailable: boolean,
submitted_by: string, // Firebase uid (anti slate-stuffing: 1/caller)
submitted_at: string, // ISO 8601
tenant_id: string
}
],
tenant_id: string // fail-closed: mismatch is denied, not passed
}
product_votes (Pillar 3)
Written by castProductVote (functions/proposal-voting.js). One vote per anonymous_hash per product. Records a member's supplier-bid preference.
{
product_id: string, // REFERENCE → product_requests
bid_id: string, // REFERENCE → product_requests.bids[].id
anonymous_hash: string, // server-derived
created_at: string, // ISO 8601 date
tenant_id: string
}
attestations
On-chain soulbound-attestation mirror, written by issueAttestationOnChainTask (functions/on-chain-tasks.js) and updated by revokeAttestation. Doc id: ${walletAddress}_${attestationType}_${contextHashHex}. Keyed per wallet rather than per tenant.
{
walletAddress: string, // base58 Solana pubkey
attestationType: number, // VERIFIED_HUMAN / VOTED / SUPPORTED_PROPOSAL codes
contextHashHex: string, // 64-char hex (per-proposal PDA context, or zero-hash)
onChainAddress: string, // attestation PDA (base58)
onChainTxSig: string | null, // issue tx signature (null if adopted from existing PDA)
status: "confirmed" | "revoked",
syncedAt: Timestamp,
revokedAt?: Timestamp
}
savings_summary
Singleton document with ID "current":
{
total_savings: number,
total_participants: number,
average_savings: number,
last_updated: string
}
identity_proofs
Written by verifyPassportProof (functions/index.js). Doc id is the nullifier (one passport = one nullifier = one identity; Sybil resistance). voterId is the Firebase uid, baked into the ZK proof's public signals — never trusted from a side channel.
{
nullifier: string, // also the doc id
voterId: string, // Firebase uid (REFERENCE → voters / users)
commitment: string, // "" until the desktop binds a Semaphore commitment (attachSemaphoreCommitment)
proofType: string, // derived from attestationId (proofTypeForAttestation)
attestationId: string | number,
trustTier: string, // trustTierFor(proofType)
verifiedAt: string, // ISO 8601 date
disclosures: Array<{ kind: "humanity" | "age" | "jurisdiction", value?: string }>
}
The humanity-seal flow (anchorIdentityCommitmentTask) writes a bridge doc keyed 0x<hashHex> with voterId: uid, commitment: "" so the desktop AccessGate (which queries by voterId) treats the user as enrolled.
Integrity checks:
voterIdmust reference an existing voter/userdisclosures[].kind == "jurisdiction"feedsassignVoterPopulations(nationality → M49 population codes)
Database Validator
The Database Validator (Admin Panel > Testing tab) runs a 3-phase validation:
Phase 1 — Schema Validation
Checks every document in all schema-registered collections:
- Required fields present
- Field types match (string, number, boolean, array, map)
- Enum values valid (status, scope, round_type, proofType, trustTier)
- Numeric fields non-negative where required
- Array structures correct (options, categories, bids)
- ISO date format validation
Phase 2 — Referential Integrity
Verifies all foreign-key references:
votes.proposal_id→ existing proposal with validoption_idsupporter_signatures.proposal_id→ existing proposalvoting_rounds.proposal_id→ existing proposaldistributions.fundId→ existing fundidentity_proofs.voter_id→ existing voterproduct_requests.proposalId→ existing proposal (when set)
Phase 3 — Aggregate Consistency
Cross-validates computed fields:
proposal.total_votesvs actual vote countproposal.options[x].votesvs per-option vote countproposal.support_countvs actual supporter signature count- Duplicate vote detection (same
anonymous_hashon same proposal) - Duplicate support detection (same
anonymous_hashon same proposal)
Results Display
- Expandable per-collection sections
- Color-coded severity: red (errors), amber (warnings), green (passes)
- Document count per collection
- Total error/warning counts
Auto-Fix (via @plantagoai/db)
Available fix operations:
| Fix | Description |
|---|---|
| missing-defaults | Add missing fields with schema default values |
| enum-normalize | Fix enum values (trim whitespace, normalize case) |
| orphan-cleanup | Delete documents whose foreign-key targets don't exist |
| aggregate-recount | Recompute aggregate counts from actual document counts |
| timestamp-repair | Fix malformed ISO date strings |
Always run with dryRun: true first to preview changes.
Data Seeding Tools
Seed Demo Data
Populates standard demo dataset:
- 7 community funds (Stockton SEED, Jackson, Denver, Austin, Chicago, Newark, LA)
- 3 distribution history records
- ~50 marketplace products with supplier bids and price comparisons
- Savings summary singleton
- Demo governance proposals
Idempotent — detects existing data and skips.
Population Seeder
Creates demo voters at a specific location:
- Google Places Autocomplete for location picking
- Configurable count: 100 to 100,000
- Auto-inferred geographic scope
- Realistic data: random wallets, ~85% verification rate, ages 18-80, 6-month registration spread
- Batch-processes 50 at a time with progress bar
Vote Seeder
Generates votes on active proposals:
- Filter by pillar (YourVoice / YourShare / YourMarket)
- Select proposal
- Configure count: 100 to 100,000
- Distribution modes:
- Random — even spread
- Landslide Approve — ~80% on first option
- Landslide Reject — ~80% on last option
- Head to Head — top 2 nearly tied (~48% each)
- Creates voter records for audit trail
- Atomic tally updates via
increment()
Voting Simulation
Quick mode: 20 random votes distributed across all active proposals.
Reset Tools
All resets require confirmation dialog.
| Tool | Scope | Behavior |
|---|---|---|
| Reset Vote Counts | Proposals only | Zeros tallies, preserves structure, does NOT delete vote docs |
| Reset Voters | Voters collection | Batch-deletes all voter docs (200 per batch) |
| Reset All & Re-seed | Everything | Phase 1 (0-70%): Clears 8 collections. Phase 2 (70-100%): Re-seeds fresh |
Export
Via @plantagoai/db's exportDb():
- JSON format with optional metadata (
_exportedAt,_collectionName) - Tenant-scoped export option
- Configurable document limit per collection
Security Rules Generation
@plantagoai/db's generateRules() produces Firestore security rules from schema definitions:
- Type validation per field
- Tenant isolation (
tenant_idscoping) - Ring-based access control
- Required field enforcement