11 KiB
API Contract — Legislative Dashboard UI (Spec §5)
Audience: dashboard build lane (t_b38e9af2), correlation lane (t_2736f40d), LLM lane (t_950780e0). One shared contract so no lane diverges.
Principle: auth-agnostic, field-name-stable, degradeable. The preferences/personalization shape does not depend on which auth method is chosen.
Base record = scraper/schema.py::BillAction (flat daily_file_records) + the correlation lane's relational tables (bills, committees, members, bill↔committee joins) + the LLM lane's per-bill tags.
Status: draft v0.1 — field names are the frozen part; values and endpoints are ratifiable. Auth method = Open Decision D1; ZIP model = Open Decision D2; scoring weights = D3.
0. Conventions
- Dates: ISO
YYYY-MM-DD(date). Times are strings ("9:30am","Upon Call of the Chair") — do NOT canonicalize to RFC3339 in the list layer; keep source form, add a derivedhearing_sortfield (see §2) for stable ordering. - Enums:
chamber_origin∈{ASSEMBLY, SENATE}.source∈{ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF, LEGINFO_BILL_RESULTS}. - Nullability: fields a daily-file event may legitimately lack (
hearing_time,location,committeefor floor items,tagspre-LLM) are nullable. Consumers must render aTBD/omitted state, never crash on null. - IDs:
bill_id= leginfo canonical ("202520260AB28").legislator_id,district_id,tag_id,user_id= system-owned opaque IDs (snake_case, stable). - Auth: every
/person//prefs//watch//topicsand the/billspersonal view requires a bearer token (method per D1)./bills,/tags,/legislators,/districts/resolveare public. - Errors (§6): HTTP status +
{ "error": { "code", "message", "field?" } }.fieldon 400. - Versioning: contract at
/api/v1/*. Breaking changes → new major; add fields, never rename/delete (the frozen field names).
1. Shared record: Bill (what the UI consumes)
{
"bill_id": "202520260AB28",
"bill_number": "AB 28",
"chamber_origin": "ASSEMBLY",
"author": "Schiavo et al.",
"short_title": "Waste: requires recycling standards…",
"description": "Same as short_title for daily-file; enrichment may expand.",
"committee": "Resources and Water",
"hearing_time": "9:30am",
"hearing_sort": 55800,
"location": "State Capitol, Room 444",
"date": "2026-09-07",
"session": "2025-2026",
"item_number": 14,
"supplement": 2,
"source": "ASSEMBLY_DAILY_FILE",
"tags": ["waste", "environment"],
"relevance": {
"score": 5,
"matched": [
{ "type": "watched-legislator", "value": "Leg. Schiavo (A-14, chair)" },
{ "type": "tag", "value": "waste" }
]
}
}
hearing_sort(derived, backend-owned): seconds-from-midnight for HH:MMam/pm;"Upon Call of the Chair"/"Call of the Chair"→ max (sorts last); null → max (withhearing_time: null). Enables the within-day sort in DESIGN_DASHBOARD §4.relevancepresent for logged-in requests only; omitted/score:0for anon.matched[]powers theWhy is this here?transparency UI. Each entry:type∈{watched-legislator, committee-of-watched-legislator, tag},value= human-readable reason.tagsmay benull(no LLM analysis yet) or[]→ UI renders no chips, ranking degrades to legislator-match only.
Bill list envelope
{
"session": "2025-2026",
"as_of": "2026-09-05T18:00:00Z",
"chamber_availability": { "ASSEMBLY": "available", "SENATE": "pending" },
"items": [ /* Bill[] */ ],
"groups": {
"2026-09-07": 14,
"2026-09-08": 31
}
}
chamber_availabilitydrives the "Senate unavailable" note — never surface Senate rows when"pending".- Personal view returns
itemsin floated-first order with thePersonalBandslice (top N) derivable byrelevance.score > 0thenhearing_sort.
2. Endpoint GET /api/v1/bills
Query: ?upcoming=1&from=2026-09-01&to=2026-09-30&chamber=ASSEMBLY&tags=waste,health&author=…&q=recycling&sort=hearing_limit&personal=1
upcoming=1→ only future-dated events (date >= as_ofand/or not yet acted on) — see §5 "upcoming" definition.personal=1(logged in) → apply the float-to-top ranking (§4 of DESIGN_DASHBOARD); response carriesrelevanceon items.- Public without token; token upgrades the view (adds
relevance, enablespersonal=1). Response: the Bill-list envelope (§1). HTTP 200. Empty →items: []+groups: {}(UI renders/billsempty-state, not an error).
3. Personalization endpoints (logged-in)
3.1 POST /api/v1/districts/resolve — ZIP/address → districts → legislators
// request (one of the input modes; Open Decision D2 sets which is primary)
{ "zip": "90210" } // fast path (may be ambiguous)
{ "address": "…" } // authoritative (Legislature full-address tool)
{ "mode": "manual", "district": "A-14" } // fallback
// response
{
"resolved_from": "zip", // | "address" | "manual"
"confidence": "single" | "ambiguous" | "zero" | "out-of-state",
"districts": [
{
"district_id": "A-14",
"type": "ASSEMBLY", // | "SENATE"
"number": 14,
"assembly_member": { "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D" },
"senate_member": { "legislator_id": "L-sen-08", "name": "R. Riu", "party": "R" }
}
],
"suggested_topics": ["waste", "housing"], // derived from member committees ∩ session tag cloud
"suggested_watch": ["L-asm-14", "L-sen-08"]
}
- Confidence semantics (must match §8 of DESIGN_DASHBOARD):
single→ UI auto-advances to the candidate/legislator list.ambiguous/zero/out-of-state→ UI shows the §6.4 state (candidates to confirm / manual picker / "not in CA"), never auto-watches. - Source:
findyourrep.legislature.ca.gov(by address) +data.ca.govboundary datasets (by zip). Re-verify freshness at redistricting. - Resolve does not persist — it proposes. Persisting watches happens in §3.3. (Trusting-resolve = trust requirement; see DESIGN §8.)
3.2 GET /api/v1/legislators?q=… — browse / find legislators
{ "items": [
{ "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D",
"chamber": "ASSEMBLY", "district": 14,
"committees": ["Resources and Water"],
"domains": ["waste", "water"] } ] }
3.3 GET / PUT /api/v1/prefs — the personalization store
{
"user_id": "u_001",
"zip": "90210", // optional (address lookup may leave null)
"address": null, // optional, only if D2 makes address the input
"home_chamber": "ASSEMBLY", // optional hint
"districts": [ { "district_id": "A-14", "type": "ASSEMBLY", "number": 14 } ],
"watched_legislators": [ "L-asm-14", "L-sen-08" ],
"opted_in_tags": [ "waste", "health" ],
"updated_at": "2026-09-05T18:00:00Z"
}
GETreturns current prefs (or404if none → UI shows setup card).PUTreplaces the whole blob. Granular helpersPOST /prefs/districts/confirmetc. allowed but not required.- This shape is auth-method-independent — D1 doesn't change it.
3.4 POST /api/v1/prefs/watch & DELETE /api/v1/prefs/watch/:legislator_id
Toggle a single watch (optimistic-update friendly; returns the updated watched_legislators). Same for topics:
3.5 POST /api/v1/prefs/topics & DELETE /api/v1/prefs/topics/:tag_id
- 201 on add / 200 on remove; 400 with
fieldon unknown id. Idempotent.
4. Reference data endpoints (public)
4.1 GET /api/v1/tags — the tag cloud (from the LLM lane)
{
"as_of": "2026-09-05T18:00:00Z",
"tags": [
{ "tag_id": "tag:health", "label": "Health", "count": 214 },
{ "tag_id": "tag:education","label": "Education", "count": 188 },
{ "tag_id": "tag:waste", "label": "Waste", "count": 42 }
]
}
count= number of bills carrying the tag (drives the· 42 billsUI affordance and the "top topics" teaser). May be sparse early (LLM lane warming) — UI shows the "filling in" hint, never a 0-count dead chip.- Ownership note: the taxonomy/labels are produced by the LLM lane (
t_950780e0); this contract fixes their shape, not their values. A futurecuratorrole (out of scope) would own label normalization.
4.2 GET /api/v1/legislators & GET /api/v1/districts — public browse (from correlation lane)
Same shapes as §3.2 / the districts[] items; no token needed.
5. "Upcoming" — the exact, testable definition
upcoming = a bill event satisfying all:
event_date = COALESCE(date, hearing date)andevent_date >= as_of(future-dated), and- not in a terminal "acted/closed" state the source marks (a committee that already passed it out of committee, or floor passage) — i.e., the event is an open future moment, not a historical action.
- For a bill with multiple future events (committee hearing then floor on different days), each event is its own upcoming row; dedupe only within a
date.
- If the source can't confirm state #2, default to including (false-negative risk: dropping a real hearing is worse than showing a closed one) and mark
possibly_closed: true(future field; not required for v0.1). - "This week" / "This month" filters are just
from/towindows overevent_date.
6. Errors (all endpoints)
{ "error": { "code": "invalid_zip" | "ambiguous_district" | "district_not_found"
| "out_of_state" | "tag_unknown" | "legislator_unknown"
| "not_authenticated" | "prefs_not_set" | "chamber_unavailable",
"message": "human-readable", "field": "zip" } }
400invalid/ambiguous/out-of-state (withfield).401not authenticated.404unknown id / no prefs.409race on concurrent watch add (client retries idempotently).503chamber unavailable (e.g. Senate antibot) — not a hard client error; UI renders "Senate unavailable" and continues on Assembly.503also when the LLM tag service is down → UI degrades, no tag chips, ranking by legislator only.
7. Contract notes for each lane
- Correlation lane (
t_2736f40d): owns §3.1 resolve + §4.2 legislators/districts fromcorrelation_sources.member_rosters(findyourrep + data.ca.gov). Must produce thedistricts[]+assembly_member/senate_memberjoin to members. - LLM lane (
t_950780e0): ownstags[]onBill(§1) and §4.1GET /tags. Must tolerate the deterministic fallback tagger (configurable ollama) sotagsdegrades to a known small set when ollama is down — v0.1tagsmay be[]/null until warmed. - Dashboard build (
t_b38e9af2): implements the float-to-top ranking fromrelevance.score(§1) per DESIGN_DASHBOARD §4; consumes this contract verbatim. Auth method per D1 is chosen here but the shapes above are frozen regardless. - QA: assert
hearing_sortordering,upcomingboundary (a T-1 day is excluded),chamber_availabilitygates Senate rows, and the 6 empty states each return200 items:[](not 4xx/5xx).
Companion docs: design/DESIGN_DASHBOARD.md (IA + flows + visual system), design/wireframes/*.html (3 mockups). Open Decisions: D1 auth method, D2 ZIP vs address primary input + data-source provenance, D3 scoring weights.