# 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 derived `hearing_sort` field (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`, `committee` for floor items, `tags` pre-LLM) are nullable. Consumers must render a `TBD`/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`/`/topics` and the `/bills` personal view requires a bearer token (method per D1). `/bills`, `/tags`, `/legislators`, `/districts/resolve` are public. - **Errors** (§6): HTTP status + `{ "error": { "code", "message", "field?" } }`. `field` on 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) ```json { "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** (with `hearing_time: null`). Enables the within-day sort in DESIGN_DASHBOARD §4. - **`relevance`** present for **logged-in** requests only; omitted/`score:0` for anon. `matched[]` powers the `Why is this here?` transparency UI. Each entry: `type` ∈ `{watched-legislator, committee-of-watched-legislator, tag}`, `value` = human-readable reason. - **`tags`** may be `null` (no LLM analysis yet) or `[]` → UI renders no chips, ranking degrades to legislator-match only. ### Bill list envelope ```json { "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_availability` drives the "Senate unavailable" note — never surface Senate rows when `"pending"`. - Personal view returns `items` in **floated-first order** with the `PersonalBand` slice (top N) derivable by `relevance.score > 0` then `hearing_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_of` and/or not yet acted on) — see §5 "upcoming" definition. - `personal=1` (logged in) → apply the float-to-top ranking (§4 of DESIGN_DASHBOARD); response carries `relevance` on items. - Public without token; token *upgrades* the view (adds `relevance`, enables `personal=1`). **Response:** the Bill-list envelope (§1). HTTP 200. Empty → `items: []` + `groups: {}` (UI renders `/bills` empty-state, not an error). --- ## 3. Personalization endpoints (logged-in) ### 3.1 `POST /api/v1/districts/resolve` — ZIP/address → districts → legislators ```json // 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.gov` boundary 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 ```json { "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 ```json { "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" } ``` - `GET` returns current prefs (or `404` if none → UI shows setup card). `PUT` replaces the whole blob. Granular helpers `POST /prefs/districts/confirm` etc. 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 `field` on unknown id. Idempotent. --- ## 4. Reference data endpoints (public) ### 4.1 `GET /api/v1/tags` — the tag cloud (from the LLM lane) ```json { "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 bills` UI 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 future `curator` role (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**: 1. `event_date = COALESCE(date, hearing date)` and `event_date >= as_of` (future-dated), **and** 2. 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. 3. 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`/`to` windows over `event_date`. --- ## 6. Errors (all endpoints) ```json { "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" } } ``` - `400` invalid/ambiguous/out-of-state (with `field`). `401` not authenticated. `404` unknown id / no prefs. `409` race on concurrent watch add (client retries idempotently). `503` chamber unavailable (e.g. Senate antibot) — **not a hard client error**; UI renders "Senate unavailable" and continues on Assembly. `503` also 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 from `correlation_sources.member_rosters` (findyourrep + data.ca.gov). Must produce the `districts[]` + `assembly_member`/`senate_member` join to members. - **LLM lane (`t_950780e0`):** owns `tags[]` on `Bill` (§1) and §4.1 `GET /tags`. Must tolerate the **deterministic fallback tagger** (configurable ollama) so `tags` degrades to a known small set when ollama is down — v0.1 `tags` may be `[]`/null until warmed. - **Dashboard build (`t_b38e9af2`):** implements the float-to-top ranking from `relevance.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_sort` ordering, `upcoming` boundary (a T-1 day is excluded), `chamber_availability` gates Senate rows, and the 6 empty states each return `200 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.**