Civiqapp architecture

Caching & local storage

Five kinds of store, each chosen for a reason: secrets in hardware-backed storage, relational civic data in SQLite, large blobs and state files on disk, and everything ephemeral in memory.

Map of all storage locations: secure storage, SQLite tables, application-support files, cache directory, and in-memory caches
The full storage map. Placement follows sensitivity and lifetime: the more durable or sensitive, the further left.

Placement rules

Tokens go in the Keychain/Keystore and nowhere else — the token store's contract states they must never be written to SQLite, preferences, or logs. Relational data that gets queried (representatives, terms, offices, committees, bills) lives in SQLite. Large payloads that are fetched whole and parsed whole (dataset JSON, district GeoJSON) are cached as files with ETag or SHA validators, because storing megabyte blobs in SQLite buys nothing and file rename gives atomic replacement for free. Small state machines (attestation state, pending crash report, session marker) are individual JSON files written atomically — temp file, then rename — so a crash mid-write can never leave a half-file. Anything that can be cheaply recomputed stays in memory.

The SQLite schema

One database, tth_congress_members.db, schema version 6, foreign keys on, every write inside a transaction. The doc comment at the top of DatabaseService is the schema's source of truth; this is the shape and intent:

TableHoldsNotes on design
app_metadataKey/value stringsDataset SHA markers; cached user profile. The keystone of SHA-gated sync.
representativesOne row per rep, all levelsPartial unique indexes per chamber. State reps keep district NULL so multi-member state districts can't replace each other; the district lives on the term.
representative_termsCurrent term per repUnique on (rep, start, end, type); type strings match the upstream feed spellings.
representative_officesOffice addresseslat/lng start NULL and are filled by on-device geocoding, persisted so future sessions skip the geocoder.
social_media_profilesSocial handlesSynced with the socials dataset.
jurisdictionsCanonical hierarchyPresent for future local-level data; unused by current flows.
committees / sub_committeesCommittee catalogCase-insensitive unique identifiers from the feed.
committee_memberships / sub_committee_membershipsRep ↔ committee joinsUnique per pair; batch-inserted with a single uuid→id map instead of N+1 lookups.
images / representative_imagesImage records + joinsDeduped by content hash; one image per (rep, kind).
billsBill cachecached_at/expires_at carry the status-based TTL; keyed locally because server bill ids repeat across Congresses.
representative_billsRep ↔ bill linksReplaced per rep on refresh; orphaned bills reclaimed only when no rep links them.
representative_bill_checksNegative cache"Checked and found nothing" marker, 24 h TTL — an empty result that costs no network.

Migrations (versions 2→6) tell the schema's history: deduplicating terms, adding the bills tables, dropping ambiguous unique indexes that could collide national and state reps, and twice normalizing position/term vocabulary to canonical spellings.

File caches and their validators

The API client caches GET responses as files named by path (slashes become underscores) in the application-support directory. Two validator schemes coexist. Dataset files (/representatives/congress/*, state legislators) are validated by SHA comparison against the status endpoints — the file plus its app_metadata marker together mean "seeded", which is why a failed fetch must never write a file (a spurious empty file would read as a seeded dataset). District GeoJSON uses HTTP ETags with a sidecar .etag file and If-None-Match, giving 304-for-free freshness on payloads up to 1.2 MB. The ETag write ordering is deliberate — stage body, delete old ETag, rename body, write new ETag — so the cache can never hold a new ETag with an old body; the worst crash outcome is an unvalidated body forcing one full refetch.

These same file caches are the entire data source in offline mode: GETs read the file, fall back to bundled seed assets under assets/offline/, and POSTs throw.

In-memory caches

Each in-memory cache exists to absorb a specific hot path: the representatives query LRU (50) absorbs location/filter churn; the bill LRU (50) makes reopening a rep's legislation sheet instant; the ImageProvider LRU (100) exists because providers are resolved during paint and each resolution costs a synchronous file stat; community feeds and cursors make hub-switching free; the profile photo bytes render the title-bar avatar synchronously; the geocode cache (with negative entries) protects the OS geocoder's aggressive rate limits. None survive a restart, and all of the service-level ones are invalidated by the dataset version token or explicit clears when underlying data moves.

Concurrency at the storage boundary

The API client serializes by key through two independent pools of 256 striped locks — one for requests (path + query), one for cache files (path only). They are separate on purpose: an auth-recovery request fired from inside a cache operation must not deadlock against that operation's own lock. The reporting subsystem serializes its file mutations with a single reentrant lock, and every JSON state file in the app is written via temp-then-rename with recovery logic that promotes or discards leftover temp files on the next read.

StoreLifetimeInvalidation
Keychain/Keystore tokensUntil sign-out / revocationCleared on terminal refresh failure
SQLite civic dataIndefiniteSHA-gated upsert; bills by TTL sweep
Dataset file cachesIndefiniteSHA mismatch → refetch
District GeoJSONIndefiniteETag / 304
Rep photos (cache dir)OS may reclaimRe-resolved through the image pipeline
State/crash JSON filesPurpose-scopedExplicit lifecycle (attest, prompt, sweep)
In-memory LRUsProcessVersion token, explicit clears, eviction