Visual companion to sync-pipeline.md. Last revised 2026-05-27.
sync-pipeline.md. If this diagram disagrees with the text doc, the text doc wins.
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
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
getBill per matched-changed bill, typically a small fraction of total quota.
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
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.
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
| Field | Values | Means |
|---|---|---|
match_type | 'keyword' · 'manual' · null | Tracking tier. null = monitoring-only. |
text_status | 'in_r2' · 'available' · 'no_texts' · 'not_checked' · null | Whether central confirms full text. Server-derived. |
ai_processed_at | timestamp · null | Whether AI has run and when. |
| Store | Contents |
|---|---|
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.
| Endpoint | Ingestor? | API cost | Use case |
|---|---|---|---|
| Cron (hourly) | For matched changes | 10/session/day + getBill per match | Steady state |
POST /tenants/reprocess/:tenantId | No | 0 | Refresh tenant rows from central. AI dedups on text hash. |
POST /admin/refresh-stubs/:tenantId | No | 0 | Same, scoped to match_type=null rows. |
POST /admin/fetch-missing-texts/:tenantId | Yes | 1/bill missing text | Heal text gaps. |
POST /admin/reingest-bill/:billId | Yes | 1 | Single-bill refresh. |
POST /admin/reingest-tenant/:tenantId | Yes (dry-run default) | 1/matched bill | Bulk backfill. Pass ?confirm=true to actually fire. |
POST /tenants/promote-bill/:tenantId/:billId | Yes, forceAI | 1 | Manual add: match_type='manual'. |
| Bulk seed (script) | Yes, skipFetch | 0 | Initial seed from LegiScan bulk JSON. |
Durable properties of the current design, not a work queue. Each is verified against the code, not a historical note.
bills.title/status/description before queuing, so the ingestor's snapshot reads the post-change value and detectChanges never emits title_changed/status_change/description_changed for cron-triggered messages. Child-collection diffs (history, votes, etc.) are still captured correctly.getBill returns vote summaries only, so live ingest never populates per-legislator vote rows; per-legislator records would need getRollCall calls. Bulk-seeded bills have them.change_hash = '', which forces a full update the next time the ingestor or full pass sees the bill. Cosmetic.match_type=null bills update only on the full pass (~8h) while matched bills also update on the raw pass (~1h). That relies on the full pass seeing the change, which is why the raw pass deliberately leaves an unmatched bill's change_hash stale rather than advancing it. POST /admin/backfill-stub-actions/:tenantId exists to heal stubs affected before that behavior was in place.