Election 2027
← All documents

Votewise — System Architecture & Data Flow

1. Design principles

  1. Election day is the design load, not the average day. Normal traffic is SEO-driven and cacheable. On polling day, traffic can jump 50–100x in minutes, mostly on mobile data with bad connections. Everything public is built so it can be served from the CDN edge with the origin turned off.
  2. Read and write paths are separate. Citizens mostly read (directory, locator, results), which goes edge → cache → read replicas. Writes (parallel reports, EC8A uploads, ad events) go into queues and are processed asynchronously, so a spike never blocks reads.
  3. Political data is sensitive personal data under the NDPA 2023. Political opinion is a "sensitive personal data" category. We never build per-user political profiles. Ads target geography only, and B2B analytics only ever see aggregates with k-anonymity thresholds applied.
  4. Money is exact. All amounts are integer kobo (the ad server uses millikobo internally). There are no floats in billing.

2. Topology

3. Key data flows

3.1 Citizen reads a candidate page (SEO path)

Ads are fetched after first paint, in a fixed-size slot so the layout does not shift. If the ad call fails or takes longer than 800 ms, the slot falls back to a house ad (for example, "Check your PVC status").

3.2 Election-day parallel report (write spike)

  1. The client posts a report. Writes require a Turnstile token, and the client queues the report locally in IndexedDB and retries with backoff if it is offline.
  2. tracker-svc validates the request (PU exists, a 10-minute per-device rate limit per PU, and the election is active), then acknowledges immediately and writes to the event stream.
  3. Workers then:
    • increment per-PU, per-ward, per-LGA and per-state counters in Redis (INCR/HINCRBY);
    • persist the raw report to Postgres in batches;
    • compute a trust score from report agreement, account age and photo evidence.
  4. Live tally pages poll a CDN-cached snapshot (s-maxage=15) that a worker rebuilds from Redis. The origin never sees the read fan-out.

3.3 Candidate claims a profile → buys ads

  1. A candidate (or a campaign manager acting for one) starts a claim and uploads documents: NIN slip, the INEC nomination form (EC9), and a party nomination letter.
  2. verification-svc checks NIN through a licensed NIMC verification partner and matches the claim against the INEC published candidate list. A human reviewer approves it, and the candidate gets the VERIFIED_CANDIDATE role and a verified badge.
  3. In the Studio, they create an Advertiser (CAC/RC number, billing contact), a Campaign (budget, CPM or flat fee, dates), Geo targets (which must fall inside the candidate's own constituency for constituency-level offices) and Creatives.
  4. Political campaigns enter PENDING_ARCON. The campaign stays non-servable until a compliance officer records the ARCON approval reference and certificate.
  5. After payment is captured (Paystack/Flutterwave webhook → ledger), campaign-svc publishes the campaign into the Redis serving index (see 03-ad-engine.md).

3.4 Analytics pipeline

Page views and searches go into the event stream with only coarse geo (LGA) and a topic classification. No user ID or IP is stored in analytics tables. Nightly and hourly jobs in the warehouse build TopicAggregate / EngagementAggregate rows. Any cell with fewer than 50 distinct sessions is suppressed, and the results are copied to Postgres for the B2B API.

4. Election-day operating mode

A feature flag ELECTION_DAY_MODE (set per election) switches on:

ConcernNormalElection-day mode
Public page TTL300 s ISR30–60 s, stale-while-revalidate=600, stale-if-error=86400
AdsFull auctionSponsorship-only and pre-built geo bundles served from Workers KV. CPM auction disabled on tracker/results pages
Political adsAllowedHard-blocked in the affected geography from 24 h before polls open until polls close (Electoral Act 2022, s.94)
WritesDirectQueue-first, async persistence
Origin failure5xxWorkers serve the last good snapshot with a "Last updated HH:MM" banner
ImagesNormalForced ?q=50&w=480 variants; EC8A thumbnails only, full image on tap

Pre-scale ahead of time. Warm-up runs at T-48 h, and a 10x synthetic load test (k6) is mandatory at T-7 days.

5. Mobile, 3G and PWA budget

  • The JS budget is ≤ 120 KB gzipped on public routes. Use React Server Components by default, with client components only for the locator map, report form and ad slot.
  • Maps load only on intent. The PU locator starts as cascading selects plus "use my location" (a PostGIS nearest-PU query). Mapbox/Leaflet loads only when the user taps "Show map", and tiles are simplified GeoJSON (Mapshaper, ≤ 5% vertices) served as vector tiles from the CDN.
  • The service worker (Workbox) uses these strategies: precache the app shell; stale-while-revalidate for candidate and geo JSON; network-first with offline fallback for results; and background sync for reports.
  • Fonts: Inter and Plus Jakarta Sans, self-hosted through next/font, latin subset, 2 weights each, font-display: swap.

6. Geo resolution strategy (important for Nigeria)

IP geolocation in Nigeria is unreliable below state level. Mobile carriers use CGNAT and often exit through Lagos or Abuja. Location is resolved in this order of precedence:

  1. Declared location: the user's saved PU/ward/LGA from the locator (first-party cookie vw_geo, no PII).
  2. Device location, with consent, snapped to the LGA polygon using PostGIS ST_Contains.
  3. Cloudflare cf-region-code (e.g. LA → Lagos), which is trusted for state only.
  4. National (no geo).

The client sends the resolved geo_state and geo_lga. The ad server re-checks them against the edge-supplied state and records an geo_confidence value of declared | device | ip_state | none. Advertisers can choose to buy "declared or device only" inventory at a premium.