FloorVote — Architecture

Visual companion to sync-pipeline.md. Last revised 2026-05-27.

Source of truth: sync-pipeline.md. If this diagram disagrees with the text doc, the text doc wins.

1. Pipeline overview

One LegiScan API, one cron, one central, fan-out to per-tenant queues. one metered API account

flowchart LR
    LS[(LegiScan API)]
    subgraph CENTRAL [Central Worker]
        Cron[Cron
0 * * * *] Ingestor[Ingestor
processLsBill] end CentralDB[("Central D1
bills + children")] CentralR2[("Central R2
bill text files")] IQ{{"Ingestor Queue"}} TQ{{"Per-tenant Queues"}} subgraph TENANT [Per-tenant Worker] TenantProc[Tenant Consumer
processCentralNotification] end TenantDB[("Tenant D1
denormalized bill
+ engagement")] UI[Bill UI] Admin[/Admin routes/] LS -->|"getMasterList
getMasterListRaw"| Cron Cron -->|"writes change_hash
+ masterlist fields"| CentralDB Cron -->|"matched bills"| IQ Cron -->|"stubOnly for
monitoring-only changes"| TQ IQ --> Ingestor Ingestor -->|"getBill"| LS Ingestor -->|"writes everything"| CentralDB Ingestor -->|"downloads bill text"| CentralR2 Ingestor -->|"notifyLsTenants"| TQ TQ --> TenantProc TenantProc -->|"GET /bills/:id
(no API call)"| CentralDB TenantProc -->|"upserts row + AI"| TenantDB TenantDB --> UI Admin -.->|"reprocess, refresh-stubs,
refresh-metadata"| TQ Admin -.->|"reingest-bill,
reingest-tenant, etc."| IQ classDef api fill:#fef3c7,stroke:#f59e0b classDef worker fill:#ede9fe,stroke:#8b5cf6 classDef store fill:#dbeafe,stroke:#3b82f6 classDef queue fill:#dcfce7,stroke:#22c55e classDef ui fill:#fce7f3,stroke:#db2777 class LS api class CentralDB,CentralR2,TenantDB store class IQ,TQ queue class UI ui class Admin api
External API / admin trigger
Worker code
Data store
Queue
UI

2. Phase 1 — Cron decision tree

Runs hourly. Most ticks are no-ops; full passes (3×/day) and raw passes (7×/day) do the actual work. The cron is the LegiScan API gate — only matched bills are queued to the ingestor.

flowchart TD
    Tick[Hourly tick at 0 * * * *]
    Tick --> Per[For each session in tracked states]
    Per --> Mode{decideMode
session, etHour} Mode -->|sine_die or
sync disabled| Skip[skip — no API call] Mode -->|ET 5/13/23| Full[getMasterList full] Mode -->|ET 7/9/11/15/17/19/21| Raw[getMasterListRaw] Full --> FullLoop[For each masterlist entry] FullLoop --> FullHash{change_hash
changed?} FullHash -->|no| Done1[ ] FullHash -->|yes| WriteBills[Write bills row
title, status, last_action,
change_hash] WriteBills --> Match[Keyword-match against
per-tenant keyword union] Match --> Decide{matched in
≥1 tenant?} Decide -->|yes — new or
changed match| ToIng[→ Ingestor Queue
billId] Decide -->|no, but link
exists w/ match_type=null| ToStub[→ Tenant Queue
stubOnly: true] Decide -->|no link, no match| Done2[ ] Raw --> RawLoop[For each raw entry] RawLoop --> RawHash{change_hash
changed?} RawHash -->|no| Done3[ ] RawHash -->|yes, new bill| RawInsert[INSERT change_hash=''
NOT queued] RawHash -->|yes, existing| RawMatched{already
matched?} RawMatched -->|yes| RawUpdate[UPDATE change_hash
+ queue to ingestor] RawMatched -->|no| Done4[leave hash stale
full pass will catch it] RawUpdate --> ToIng ToIng --> Quota["1 getBill call
per queued bill"] classDef apicall fill:#fef3c7,stroke:#f59e0b classDef decision fill:#f1f5f9,stroke:#94a3b8 classDef done fill:#f8fafc,stroke:#cbd5e1,stroke-dasharray:3 3 classDef queue fill:#dcfce7,stroke:#22c55e class Full,Raw apicall class Mode,FullHash,RawHash,Decide,RawMatched decision class Done1,Done2,Done3,Done4,Skip,RawInsert done class ToIng,ToStub,Quota queue

Cron quota cost

Per session: 3 full + 7 raw = 10 masterlist calls/day (~300/month). Across N active sessions × ~10/day each, this is the dominant fixed cost. The variable cost is getBill per matched-changed bill, typically a small fraction of total quota.

3. Phase 2 — Ingestor (unified path)

Consumes from the ingestor queue. Two top-level branches gated by the skipFetch flag.

flowchart TD
    Msg[Message arrives
billId, skipFetch?, forceMetadata?, forceAI?] Msg --> Skip{skipFetch?} Skip -->|true| DL[Download missing text
from state_link → R2] DL --> Notify Skip -->|false| GB[getBill
1 LegiScan API call] GB --> Snap[Snapshot existing child rows
history, sponsors, texts, etc.] Snap --> Detect[detectChanges
compute ChangeRecord list] Detect --> Log[Write bill_change_log rows
one per detected change] Log --> UpBills[Upsert bills row
change_hash, title, status, last_action] UpBills --> Replace["Delete + reinsert:
bill_history
bill_sponsors
bill_sasts
bill_subjects
bill_calendar
bill_referrals"] Replace --> UpsertChildren["Upsert (preserves r2_key):
bill_texts
bill_supplements
bill_amendments
roll_calls"] UpsertChildren --> TextDL[Download new text versions to R2
bills/legiscan-billId/texts/docId.ext] TextDL --> Stamp[Stamp bills.texts_fetched_at
derives text_status] Stamp --> Notify[notifyLsTenants
for each covering tenant] Notify --> End[ack message] classDef apicall fill:#fef3c7,stroke:#f59e0b classDef decision fill:#f1f5f9,stroke:#94a3b8 classDef write fill:#dbeafe,stroke:#3b82f6 classDef done fill:#f8fafc,stroke:#cbd5e1 class GB apicall class Skip decision class Log,UpBills,Replace,UpsertChildren,TextDL,Stamp,DL write class End,Notify done
roll_call_votes note: getBill returns vote summaries (LegiscanVoteSummary), not per-legislator records. Live-ingested bills have summary roll_calls rows but no roll_call_votes rows. Bulk-seeded bills have both.

4. Phase 3 — Tenant consumer

Reads from central via HTTP (no LegiScan calls). Three behaviors gated by message flags.

flowchart TD
    Msg[Message arrives
tenantId, billId, flags...] Msg --> Fetch["centralFetch /bills/billId
D1 read, no API call"] Fetch --> Branch{which flag?} Branch -->|stubOnly: true
cron full pass only| StubGuard{existing has
aiProcessedAt or
match_type='manual'?} StubGuard -->|yes| StubSkip[Skip - race protection
don't downgrade manual/promoted bill] StubGuard -->|no| StubUpsert[Upsert metadata
match_type from msg or null
no text, no AI] Branch -->|metadataOnly: true
tenant admin only| MdUpsert[Upsert metadata
no text, no AI] Branch -->|normal| Dedup{providerUpdatedAt
unchanged AND no force flags
AND aiProcessedAt set?} Dedup -->|yes| Done1[ ] Dedup -->|no| TS{text_status
available or in_r2?} TS -->|yes| FT[Fetch text from central
HTTP, no API call] TS -->|no| NoText[skip text fetch] FT --> AICheck{shouldRunAi?
forceAI OR derivedMatchType ≠ null} NoText --> AICheck AICheck -->|no| WriteOnly[Upsert bill row
no AI fields] AICheck -->|yes| TextOk{has full text
AND not aiDedup?} TextOk -->|no| WriteOnly TextOk -->|yes| RunAI[Run Gemini
fallback Claude on 429/503] RunAI --> WriteAI[Upsert bill row
+ ai_processed_at
+ summary, tags, relevance
+ last_ai_text_hash] WriteAI --> FeedEvent{matchType ≠ null
AND queueChanges?} WriteOnly --> FeedEvent StubUpsert --> Done2[ ] MdUpsert --> Done3[ ] FeedEvent -->|yes| Emit[Write feed_events: bill_updated] FeedEvent -->|no| Done4[ ] classDef decision fill:#f1f5f9,stroke:#94a3b8 classDef write fill:#dbeafe,stroke:#3b82f6 classDef ai fill:#ede9fe,stroke:#8b5cf6 classDef done fill:#f8fafc,stroke:#cbd5e1 class Branch,StubGuard,Dedup,TS,AICheck,TextOk,FeedEvent decision class StubUpsert,MdUpsert,WriteOnly,WriteAI,Emit write class RunAI,FT ai class Done1,Done2,Done3,Done4,StubSkip,NoText done

Canonical bill-state fields (tenant D1)

FieldValuesMeans
match_type'keyword' · 'manual' · nullTracking tier. null = monitoring-only.
text_status'in_r2' · 'available' · 'no_texts' · 'not_checked' · nullWhether central confirms full text. Server-derived.
ai_processed_attimestamp · nullWhether AI has run and when.

5. Where things live

StoreContents
Central D1 (central-bills-ls)sessions, bills + every child table (history, sponsors, texts, supplements, amendments, sasts, subjects, calendar, referrals, roll_calls, roll_call_votes*), people, committees, bill_change_log, api_call_log, session_sync_log, bill_tenants, keyword_registry.
Central R2 (central-bill-texts-ls)bills/legiscan-{billId}/texts/{docId}.html|pdf — actual bill text files. sessions/{id}/masterlist.json — cached masterlist for keyword resync.
Tenant D1 (floorvote-{id}, one per org)The denormalized bills row (with title/sponsor/lastAction etc. inlined; canonical state via match_type + text_status + ai_processed_at), member_votes, official_positions, comments, notes, feed_events, custom fields, AI outputs.

* roll_call_votes populated only by bulk seed; live ingest doesn't fetch them.

6. Operational endpoints — quick reference

EndpointIngestor?API costUse case
Cron (hourly)For matched changes10/session/day + getBill per matchSteady state
POST /tenants/reprocess/:tenantIdNo0Refresh tenant rows from central. AI dedups on text hash.
POST /admin/refresh-stubs/:tenantIdNo0Same, scoped to match_type=null rows.
POST /admin/fetch-missing-texts/:tenantIdYes1/bill missing textHeal text gaps.
POST /admin/reingest-bill/:billIdYes1Single-bill refresh.
POST /admin/reingest-tenant/:tenantIdYes (dry-run default)1/matched billBulk backfill. Pass ?confirm=true to actually fire.
POST /tenants/promote-bill/:tenantId/:billIdYes, forceAI1Manual add: match_type='manual'.
Bulk seed (script)Yes, skipFetch0Initial seed from LegiScan bulk JSON.

7. Known limitations

Durable properties of the current design, not a work queue. Each is verified against the code, not a historical note.