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, vote-on-bids) | Yes | — | Product listings with embedded crowd-sourced supplier bids[] |
product_votes |
Individual P3 votes | Yes | — | One doc per supplier-selection vote, references product + bid |
market_pools |
Bulk-buy pools (Pillar 3, commerce agents) | Yes | committedQty, customerCount, p50Max | Gathering / RFQ / awarded pools. Source reads aggregates only |
market_catalog |
SKUs backing pools | Yes | — | Ranked search input for the Pool agent |
market_suppliers |
Known suppliers | No | — | Rating + quote hints; notes are untrusted |
market_quotes |
RFQ responses | No | — | Ops-read; notes fenced before the model sees them |
market_commitments |
Per-member demand lines | Yes | — | Own-uid client read; Functions write. Qty + max, not a charge |
market_orders |
Per-member checkout orders | Yes | — | Own-uid client read; host placeMarketOrder marks paid |
market_review_queue |
Human review items | No | — | Admin read; thin pool / low rating / disputes |
market_staged_actions |
Staged RFQ/award | No | — | Admin read; apply re-checks guardrails |
market_memory |
Typed agent facts | No | — | Own-uid read; extract from user/assistant text only |
market_agent_sessions |
Agent session metadata | No | — | Own-uid read; issued-ID registry is server-side |
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 |
proposal_nullifiers |
Per-proposal Rarimo nullifier claims | No | — | Subcollection {proposalId}/nullifiers/{nullifier}. Public read, server-write only, tombstone-never-delete |
proposal_vote_sessions |
Anonymous proposal-vote proof sessions | No | — | Server-only both directions; deliberately uid-free |
polls |
Public polls (Freedom-Tool-style) | No | — | Public read, server-write only. Mirrored on-chain (pillar1-governance) |
poll_votes |
Individual poll votes | No | — | Public read, server-write only. Doc id {pollId}_{nullifierHashHex} — the dedup gate |
poll_verification_sessions |
Poll identity-verification proof sessions | No | — | Server-only both directions. Unlike proposal_vote_sessions, carries the caller's uid (authenticated flow) |
identity_claims |
Wallet-keyed identity claims (citizenship, sex, document validity) | No | — | Server-only (allow read, write: if false). Read only via the queryIdentityClaims callable |
ops_findings |
AI-classified operational findings from scanOpsSignals |
No | — | Server-only both directions. Admin read/dismiss via listOpsFindings/dismissOpsFinding callables |
app_config |
Platform-wide config docs (founders, ops_monitor, etc.) |
No | — | Public read except ops_monitor (admin email + uid) — see firestore.rules. ops_monitor read only via getOpsMonitorConfig |
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
}
market_pools (Pillar 3, commerce agents)
Written only by Cloud Functions (functions/market.js). Clients may read if signed in. Source agents must read aggregates (committedQty, customerCount, p50Max, fillRatio) — never a member dump. See Your Market architecture — current.
{
id: string, // doc id, e.g. "olive-oil-5l"
skuId: string, // REFERENCE → market_catalog
title: string,
targetQty: number, // integer ≥ 1
committedQty: number, // AGGREGATE
customerCount: number, // AGGREGATE — unique uids, not a member list
p50Max: number, // AGGREGATE — median maxUnitPrice
fillRatio: number, // committedQty / targetQty
unitCeiling: number, // auto-award: quote.unitPrice must be ≤ this
knownSupplierIds: string[], // empty → RFQ goes to review, not auto
status: "gathering" | "rfq" | "awarded" | "allocating" | "shipping" | "complete",
awardedQuoteId?: string, // REFERENCE → market_quotes
tenant_id: string,
demo?: boolean
}
market_catalog
SKU rows the Pool agent's search_catalog ranks. Signed-in read; Functions write.
{
id: string,
title: string,
category: string,
unit: string,
poolId: string, // REFERENCE → market_pools
tenant_id?: string
}
market_suppliers
{
id: string,
name: string,
rating: number, // auto-award floor is 4.0
quoteId?: string, // deterministic quote id used at RFQ
quotePrice: number,
notes: string // untrusted — fenced before the model sees it
}
market_quotes
Ops-readable. Notes are fenced in presentation records, not stored pre-fenced.
{
id: string,
poolId: string, // REFERENCE → market_pools
supplierId: string, // REFERENCE → market_suppliers
unitPrice: number,
notes: string
}
market_commitments
One demand line per (poolId, uid). Doc id {poolId}:{uid}. Own-uid client read; Functions write. Not a charge.
{
id: string,
poolId: string,
uid: string,
qty: number, // resulting qty capped at 20
maxUnitPrice: number,
tenant_id?: string
}
market_orders
Allocated at award. Doc id {poolId}:{uid}. Own-uid client read. Host placeMarketOrder sets paid.
{
id: string,
poolId: string,
uid: string,
qty: number,
unitPrice: number,
maxUnitPrice: number,
status: "awaiting_checkout" | "paid" | "shipped" | "returned",
skuId: string,
title: string,
paymentRef?: string,
tenant_id?: string
}
market_review_queue / market_staged_actions
Admin read, Functions write. applyMarketAction re-checks guardrails at apply time.
// market_review_queue/{id}
{ id: string, poolId: string, kind: "open_rfq" | "award", reason: string }
// market_staged_actions/{id}
{ id: string, kind: "open_rfq" | "award", poolId: string, quoteId?: string, status: "staged" | "applied" }
market_memory
Typed facts. Extractor reads user/assistant text only. Own-uid read; Functions write. Disabled when MARKET_MEMORY_ENABLED=false.
{
uid: string,
key: string, // e.g. "qty_cap", "delivery_preference"
value: string,
category: string,
sessionId: string,
updatedAt: number,
expiresAt: number // 365 days from write
}
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
The Self Protocol write path (verifyPassportProof) and the Semaphore commitment path (attachSemaphoreCommitment) were both deleted in Task 8 — do not look for either. The one place a root is created today is mintPohRoot (functions/lib/poh-root.js), called from two sites:
functions/founders/passport.js— Rarimo passport verification,proofType: "rarimo-passport",provider: "rarimo".functions/lib/manual-review.js— operator-run manual review,proofType: "manual-review".
functions/user-management.js's admin manual-verify path writes a third proof directly with .set({ merge: true }) (not through mintPohRoot), proofType: "admin-manual", and hardcodes trustTier: "high" since trustTierFor() (functions/lib/tier.js) doesn't know that proof type. This is the one that renders as the orange-shield badge in the UI.
Doc id is the nullifier (one passport = one nullifier = one identity; Sybil resistance — mintPohRoot uses .create(), which fails on an existing id, as the dedup gate). voterId is the Firebase uid.
{
nullifier: string, // also the doc id
voterId: string, // Firebase uid (REFERENCE → voters / users)
commitment: string, // vestigial — always "". The Semaphore attach path that
// used to populate this was retired in Task 8; pohRootAdmits
// (lib/poh-gate.js) never reads it
provider: string, // "rarimo" today; legacy pre-Task-8 docs predate this field
// and are read as "self"
proofType: "rarimo-passport" | "manual-review" | "admin-manual",
attestationId: string | number | null,
trustTier: string, // trustTierFor(proofType) — hardcoded "high" for admin-manual
verifiedAt: string, // ISO 8601 date
disclosures: Array<{ kind: "humanity" | "age" | "jurisdiction", value?: string }>,
tenant_id: string, // read from the voter doc at mint time; "default" for
// founders members (no voters doc)
// admin-manual only:
manualReviewBy?: string,
manualReviewAt?: Timestamp,
manualReviewReason?: string | null,
}
Integrity checks:
voterIdmust reference an existing voter/userdisclosures[].kind == "jurisdiction"feedsassignVoterPopulations(nationality → M49 population codes)
proposal_nullifiers
Per-proposal Rarimo nullifier claims — the replacement for the retired Semaphore group-membership vote gate (functions/lib/proposal-nullifiers.js). The parent doc proposal_nullifiers/{proposalId} is never written; every claim lives in its nullifiers subcollection, claimed with a create-only Firestore transaction (claimProposalNullifierTx) so an existing id always wins:
// proposal_nullifiers/{proposalId}/nullifiers/{nullifier}
{
votedAt: string, // ISO 8601 date
}
Deliberately holds nothing that identifies the voter, and no optionId — that's the whole anonymity property of the flow (asserted by a key-set test in proposal-nullifiers.test.js). optionId was removed from this document 2026-09-04 (see docs/poh-threat-model.md T4): this collection is world-readable and the nullifier is voter-reproducible, so persisting optionId here would have let a voter prove their own choice to a vote buyer. The public per-option tally lives instead on proposals/{id}.options.*.votes (see above), computed server-side by castRarimoVoteImpl. firestore.rules: public read, allow write: if false on both the parent and the subcollection. Tombstone, never delete — releasing a claim would let the same passport vote twice on the same proposal.
proposal_vote_sessions
Vote-proof sessions for the anonymous proposal-vote flow (functions/anonymous-vote.js's startAnonymousVoteProofImpl). Server-only in both directions in firestore.rules (allow read/write: if false) — deliberately carries no uid, unlike poll_verification_sessions below, because an anonymous vote must never be re-linkable to the voter who cast it.
// proposal_vote_sessions/{sessionId}
{
proposalId: string,
eventId: string, // this proposal's derived Rarimo event id (proposalEventIdFor)
status: "pending" | "used",
createdAt: string, // ISO 8601 date
usedAt?: string, // stamped when castRarimoVoteImpl burns the session
}
polls
Freedom-Tool-style public polls — permissionless, nullifier-gated, Firestore-first with async on-chain mirroring (functions/polls.js, mirrored via pillar1-governance; see docs/superpowers/specs/2026-08-30-foundation-rarimo-consolidation-design.md §5). firestore.rules: public read, server-write only — canonical writer is the createPoll callable.
{
title: string, // max 32 UTF-8 bytes — seeds the on-chain Poll PDA directly
description: string, // max 512 UTF-8 bytes
options: string[], // each max 64 UTF-8 bytes
votes: number[], // parallel ARRAY, one count per option — NOT a map. Must be
// updated via a read-modify-write transaction; a dotted
// field-path update() does not address array elements
total_votes: number,
creator_uid: string,
created_at: Timestamp,
status: "open",
on_chain_address: string | null, // set by mintPollOnChainTask once minted
on_chain?: { signature: string | null, cluster: string, minted_at: string },
}
One poll per (creator, title): createPollImpl rejects an exact title reuse by the same creator inside the same transaction as the create, because a creator's nullifier hash is constant per creator under POLL_CREATION_VERIFICATION_SCOPE and a reused title would derive the same on-chain PDA.
poll_votes
Individual poll votes (functions/polls.js's castPollVoteImpl). firestore.rules: public read, server-write only — canonical writer is castPollVote. Doc id is {pollId}_{nullifierHashHex} — the .create() collision on this id is the per-identity dedup gate for the poll (same pattern as identity_proofs' nullifier-keyed doc id).
{
poll_id: string, // REFERENCE → polls
voter_uid: string,
option_index: number,
nullifier_hash_hex: string, // poll-scoped nullifier — never the voter's registration nullifier
created_at: Timestamp,
on_chain: null | {
poll_vote_record_address: string,
signature: string | null,
cluster: string,
recorded_at: string,
},
on_chain_skipped?: string, // set instead of on_chain if the voter has no custodial wallet
}
poll_verification_sessions
Identity-verification proof sessions for the poll create/vote flows (functions/polls.js). firestore.rules: server-only both directions (allow read/write: if false) — a public read would let anyone enumerate in-flight sessions, and a client write would let a caller forge a session claiming a scope it was never verified for.
// poll_verification_sessions/{sessionId} — {sessionId} is a fresh, random
// UUID, sent to verificator-svc in place of the caller's real uid
{
pollId: string, // a real poll id, OR the sentinel "poll-creation"
// (POLL_CREATION_VERIFICATION_SCOPE) for the create-poll flow
uid: string, // caller's REAL Firebase uid — see below for why this is safe here
status: "pending" | "used",
createdAt: string, // ISO 8601 date
usedAt?: string,
}
Why the real uid is stored here but never sent to Rarimo (commit f09e9b65): the session id — not the uid — is what's sent to verificator-svc as the identity string for both proof-request creation and every later readback, because verificator-svc's readback endpoints take only an identity string, no eventId. Passing the real uid would let a member's founders-registration proof (same uid, different eventId) silently satisfy a poll's verification check. Polls are an authenticated flow that already stores voter_uid/creator_uid directly on public docs, so — unlike proposal_vote_sessions above — there is no voter-anonymity property to preserve here; the stored uid exists purely for a defensive "does this session belong to the caller" ownership check.
identity_claims
Wallet-keyed, not uid-keyed — issued/refreshed by issueIdentityClaimsOnChainTask (functions/on-chain-tasks.js), enqueued from functions/founders/passport.js after a Rarimo verification. Doc id is the wallet address, and the doc is always upserted (never skipped if it exists), since re-verification can add or refresh claims.
// identity_claims/{walletAddress}
{
walletAddress: string,
claims: {
citizenship?: string, // ISO alpha-3, omitted (not "") when absent
sex?: string,
documentNotExpired?: true,
},
chain: "solana", // EVM holders are a graceful skip, not written
onChainAddress: string,
onChainTxSig: string | null,
status: "confirmed",
createdAt: Timestamp, // stamped once, on first write, never overwritten
syncedAt: Timestamp,
}
Server-only in both directions (allow read, write: if false — added by Plan A Task 7, firestore.rules:147; earlier revisions of this doc said no block existed at all, which was true before that task landed). The only client-facing read path is the queryIdentityClaims callable (functions/attestations.js), which requires an authenticated, verified member and returns a redacted subset of claims.
Why wallet-keyed matters to erasure (see functions/account-deletion.js:269-274): deleteMyAccount resolves the member's wallet address via getUserPubkey(uid) before deleteUserWallet() wipes the user_wallets/{uid} mapping — this is the only place that uid→wallet mapping is ever recorded, and identity_claims can't be matched by uid the way the rest of foundationDataMap works (field-value or doc-id match against uid). Get this ordering wrong and the claim doc silently orphans with no path back to it. Firestore erasure is immediate and unconditional (GDPR Art. 17 must not depend on a blockchain round-trip); the on-chain claims-PDA revoke is a best-effort async follow-up.
ops_findings
Added by the ops-monitoring & digest feature (2026-09-07, functions/ops-monitor/). scanOpsSignals (a 5-minute scheduled function, gated by app_config/ops_monitor's admin-configured scanIntervalMinutes) gathers Cloud Logging errors + DLQ/bypass-volume signals, has Claude classify them, and upserts one doc per finding here. Doc id is safeDocId(signature) (functions/ops-monitor/findings.js) — the AI-generated signature slugified (lowercase, non-[a-z0-9_-] collapsed to -, trimmed, capped at 200 chars, "unnamed-finding" fallback), not the raw signature itself.
// ops_findings/{safeDocId(signature)}
{
signature: string, // free text from Claude, kebab-case requested
source: string,
severity: "critical" | "high" | "medium" | "low",
summary: string,
suggestedAction: string,
sampleMessage: string,
functionName: string | null,
status: "open" | "dismissed" | "resolved",
occurrences: number, // bumped on every recurrence, including a reopen
firstSeen: Timestamp,
lastSeen: Timestamp, // bumped on every recurrence — this is what
// requalifies a reopened finding for the next digest
resolvedAt: Timestamp | null, // null while open; set by auto-resolve or dismiss
includedInDigestAt: Timestamp | null,
}
Server-only in both directions (allow read, write: if false, firestore.rules:890), same convention as identity_claims. Admin-facing reads/writes go through the listOpsFindings/dismissOpsFinding callables (functions/ops-monitor/config.js), both Ring.TENANT_ADMIN. autoResolveStaleFindings (functions/ops-monitor/findings.js) flips a still-open finding to resolved once lastSeen is older than staleAfterMs (6× the configured scan interval) — a dismissed finding stays dismissed on recurrence (only occurrences/lastSeen bump), so an admin's dismissal survives the finding reappearing.
app_config/ops_monitor
One doc (app_config/ops_monitor) holding the ops-monitor's admin-configurable settings — the reason the scan/digest schedulers are "polled interval" (fixed 5-minute Cloud Scheduler trigger, gated by this doc) rather than scheduled directly: Cloud Scheduler's own cron is fixed at deploy time, this doc isn't.
// app_config/ops_monitor
{
scanIntervalMinutes: number, // default 30, admin range 5-1440
digestIntervalMinutes: number, // default 180, admin range 5-1440
digestEmail: string, // recipient for sendOpsDigest
lastScanAt: Timestamp, // stamped by scanOpsSignals every run (due or not)
lastDigestAt: Timestamp, // stamped by sendOpsDigest every run (due or not)
}
Excluded from app_config's otherwise-public firestore.rules read rule (allow read: if configId != 'ops_monitor') because it carries an admin email — the only other doc in this collection is founders, which stays public-read. Frontend reads it via the getOpsMonitorConfig callable (Ring.TENANT_ADMIN); updateOpsMonitorConfig is Ring.PLATFORM_OWNER — tighter than the read gate, since digestEmail controls where a platform-wide, cross-tenant operational data stream (every tenant's Cloud Logging errors, identity_bypass offenders naming other tenants) gets sent.
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)market_commitments.poolId→ existingmarket_poolsdocmarket_orders.poolId→ existingmarket_poolsdocmarket_quotes.supplierId→ existingmarket_suppliersdoc
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