From 32123ebc50640498dd1a4d3894f1af3f42ca64ce Mon Sep 17 00:00:00 2001 From: i Date: Tue, 1 Sep 2026 13:25:17 -0400 Subject: [PATCH 1/7] Add Ballotpedia.idric --- Ballotpedia.idric | 139 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 Ballotpedia.idric diff --git a/Ballotpedia.idric b/Ballotpedia.idric new file mode 100644 index 0000000..920b9f0 --- /dev/null +++ b/Ballotpedia.idric @@ -0,0 +1,139 @@ +module Ballotpedia + +import Network.HTTP +import Network.Transport +import Network.URL +import System +import Transport + + +api_base : String +api_base = "https://api4.ballotpedia.org/data/elections_by_state" + +usage : String +usage = + "usage:\n" ++ + " ballotpedia url STATE YYYY-MM-DD\n" ++ + " ballotpedia elections STATE YYYY-MM-DD\n" ++ + "\nBALLOTPEDIA_API_KEY is required for elections." + +choice command one_of + show_url String String + list_elections String String + +record Candidate where + constructor make_candidate + candidate_id : String + person_id : String + name : String + party : String + office : String + district : String + profile_url : String + +-- These are narrow library/compiler watchpoints. The CLI stays Idriç-only: +-- ICU owns HTTP; Ballotpedia owns the meaning of this request and response. +environment_value : String → IO (Maybe String) +environment_value name = ?read_environment_value + +icu_get_with_headers : List HttpHeader → url → IO (Either String String) +icu_get_with_headers headers target = ?icu_response_body_with_request_headers + +decode_candidates : String → Either String (List Candidate) +decode_candidates body = ?decode_ballotpedia_candidates + +clean_field : String → String +clean_field text = ?flatten_tsv_field + +valid_state : String → Bool +valid_state state = + case unpack state of + [first, second] => + first >= 'A' && first <= 'Z' && + second >= 'A' && second <= 'Z' + _ => False + +digit : Char → Bool +digit character = character >= '0' && character <= '9' + +valid_date : String → Bool +valid_date date = + case unpack date of + [y1, y2, y3, y4, '-', m1, m2, '-', d1, d2] => + digit y1 && digit y2 && digit y3 && digit y4 && + digit m1 && digit m2 && digit d1 && digit d2 + _ => False + +elections_url_text : String → String → String +elections_url_text state date = + api_base ++ "?state=" ++ state ++ "&date=" ++ date + +parse_target : String → String → Either String url +parse_target state date = + if not (valid_state state) + then Left "ballotpedia: STATE must be two uppercase ASCII letters" + else if not (valid_date date) + then Left "ballotpedia: date must have the form YYYY-MM-DD" + else parse_url (elections_url_text state date) + +parse_command : List String → Either String command +parse_command ["url", state, date] = Right (show_url state date) +parse_command ["elections", state, date] = Right (list_elections state date) +parse_command _ = Left usage + +render_candidate : Candidate → String +render_candidate candidate = + clean_field candidate.candidate_id ++ "\t" ++ + clean_field candidate.person_id ++ "\t" ++ + clean_field candidate.name ++ "\t" ++ + clean_field candidate.party ++ "\t" ++ + clean_field candidate.office ++ "\t" ++ + clean_field candidate.district ++ "\t" ++ + clean_field candidate.profile_url + +print_candidates : List Candidate → IO () +print_candidates [] = pure () +print_candidates (candidate :: rest) = do + putStrLn (render_candidate candidate) + print_candidates rest + +fail_with : String → IO () +fail_with problem = do + putStrLn problem + exitFailure + +run : command → IO () +run (show_url state date) = + case parse_target state date of + Left problem => fail_with problem + Right _ => putStrLn (elections_url_text state date) + +run (list_elections state date) = + case parse_target state date of + Left problem => fail_with problem + Right target => do + maybe_key ← environment_value "BALLOTPEDIA_API_KEY" + case maybe_key of + Nothing => fail_with "ballotpedia: missing BALLOTPEDIA_API_KEY" + Just key => do + outcome ← icu_get_with_headers + (header "x-api-key" key :: header "Accept" "application/json" :: []) + target + case outcome of + Left problem => fail_with ("ballotpedia: " ++ problem) + Right body => + case decode_candidates body of + Left problem => fail_with ("ballotpedia: invalid response: " ++ problem) + Right candidates => do + putStrLn "candidate_id\tperson_id\tname\tparty\toffice\tdistrict\tprofile_url" + print_candidates candidates + +main : IO () +main = do + arguments ← getArgs + case arguments of + _ :: rest => + case parse_command rest of + Left problem => fail_with problem + Right value => run value + [] => fail_with usage From 248c4c14ac0d73319fa00f7dce98e524281abeab Mon Sep 17 00:00:00 2001 From: i Date: Tue, 1 Sep 2026 13:25:18 -0400 Subject: [PATCH 2/7] Add checkpoints/ballotpedia/fixture/elections_by_state.json --- .../ballotpedia/fixture/elections_by_state.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 checkpoints/ballotpedia/fixture/elections_by_state.json diff --git a/checkpoints/ballotpedia/fixture/elections_by_state.json b/checkpoints/ballotpedia/fixture/elections_by_state.json new file mode 100644 index 0000000..c9cd3b0 --- /dev/null +++ b/checkpoints/ballotpedia/fixture/elections_by_state.json @@ -0,0 +1,15 @@ +{ + "data": { + "candidates": [ + { + "candidate_id": "100", + "person_id": "200", + "name": "Ada Example", + "party": "Independent", + "office": "City Council", + "district": "Ward 1", + "url": "https://ballotpedia.org/Ada_Example" + } + ] + } +} From 4e12f6f3cfcb6026ed5fa22f8492738db2a2dbc8 Mon Sep 17 00:00:00 2001 From: i Date: Tue, 1 Sep 2026 13:25:19 -0400 Subject: [PATCH 3/7] Add checkpoints/ballotpedia/fixture/expected.tsv --- checkpoints/ballotpedia/fixture/expected.tsv | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 checkpoints/ballotpedia/fixture/expected.tsv diff --git a/checkpoints/ballotpedia/fixture/expected.tsv b/checkpoints/ballotpedia/fixture/expected.tsv new file mode 100644 index 0000000..3bbabf9 --- /dev/null +++ b/checkpoints/ballotpedia/fixture/expected.tsv @@ -0,0 +1,2 @@ +candidate_id person_id name party office district profile_url +100 200 Ada Example Independent City Council Ward 1 https://ballotpedia.org/Ada_Example From c8d545c4a32ea9d8b1a2cb9a506e31b0ba2cf4a6 Mon Sep 17 00:00:00 2001 From: i Date: Tue, 1 Sep 2026 13:25:20 -0400 Subject: [PATCH 4/7] Add checkpoints/ballotpedia/README.md --- checkpoints/ballotpedia/README.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 checkpoints/ballotpedia/README.md diff --git a/checkpoints/ballotpedia/README.md b/checkpoints/ballotpedia/README.md new file mode 100644 index 0000000..c1c4510 --- /dev/null +++ b/checkpoints/ballotpedia/README.md @@ -0,0 +1,36 @@ +# Ballotpedia CLI checkpoint + +The Idriç command is: + +```text +ballotpedia url MI 2026-11-03 +ballotpedia elections MI 2026-11-03 +``` + +It constructs an API4 `/data/elections_by_state` request and reads the API +key from `BALLOTPEDIA_API_KEY`. The key is sent only as the `x-api-key` +header. + +Ownership stays explicit: + +```text +arguments → Idriç validation → Ballotpedia request → ICU → HTTPS + → ICU response → Idriç decoding → typed candidates → TSV +``` + +## Current execution boundary + +This is an honest compiler/library checkpoint, not a shell-backed client. +The checked ICU path currently accepts only its fixed request headers and +returns transport status while streaming the HTTP response. Therefore the +source retains three named holes: + +- environment lookup; +- ICU caller-supplied headers plus captured response body; +- Ballotpedia JSON decoding. + +The `url` command and request validation do not need a key and define the +first executable acceptance surface. Do not fill the holes with curl, +Python, JavaScript, or another HTTP implementation. The next real step is +to extend the typed ICU interface, then discharge the decoder against the +synthetic fixture in this directory. From 2848591c5a240dc834240350d608a7c7f6861e9d Mon Sep 17 00:00:00 2001 From: i Date: Tue, 1 Sep 2026 13:25:37 -0400 Subject: [PATCH 5/7] Document Ballotpedia CLI checkpoint --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index abe7022..9a3e5d1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This repository is the consolidation point for the CLI/API-access programs that - `bin/az` — Amazon product/price access and append-only price observations. - `bin/abe` — AbeBooks delivered-price lookup and Impact affiliate links. - `Ap.idric` — Associated Press API checkpoint. +- `Ballotpedia.idric` — Ballotpedia API4 elections-by-state client checkpoint, entirely in Idriç with ICU as its HTTP boundary. - `Economist.idric` — Economist API checkpoint. - `Ft.idric` — Financial Times API checkpoint. - `Guardian.idric` — Guardian API checkpoint. @@ -17,7 +18,7 @@ This repository is the consolidation point for the CLI/API-access programs that - `Reuters.idric` — Reuters GraphQL checkpoint. - `Wayback.idric` — Internet Archive Wayback/CDX checkpoint. -The top-level `.idric` files are symbolic links to the canonical sources under `checkpoints/`, so the important source is visible without digging through directories. +The important Idriç sources are exposed at the repository top level so they can be inspected without digging through directories. Most are symbolic links to canonical sources under `checkpoints/`; Ballotpedia's top-level file is currently its canonical source. Some Idriç clients intentionally contain named holes for compiler/library boundaries that are not implemented yet. Keep those boundaries visible; do not make a client appear green by silently substituting another HTTP implementation. @@ -27,6 +28,6 @@ Where these clients need networking, ICU/Idric-Net remains the intended transpor ## Tests -`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit has a separate manual compiler checkpoint at `checkpoints/reddit/check`; it is not part of `make test` while named Idriç holes remain. +`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit and Ballotpedia have separate checkpoints under `checkpoints/`; they are not part of `make test` while named Idriç holes remain. See `PROVENANCE.md` for the source branches copied into this repository. From 4d3ba3527f9b43272ff327748498142a2f6c990f Mon Sep 17 00:00:00 2001 From: i Date: Wed, 2 Sep 2026 14:20:50 -0400 Subject: [PATCH 6/7] =?UTF-8?q?Wire=20Ballotpedia=20through=20current=20Id?= =?UTF-8?q?ri=C3=A7=20and=20ICU=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ballotpedia.yml | 140 ++++++++ Ballotpedia.idric | 313 ++++++++++++++---- Makefile | 9 +- README.md | 10 +- ballotpedia.ipkg | 9 + checkpoints/ballotpedia/README.md | 63 +++- .../fixture/elections_by_state.json | 27 +- checkpoints/ballotpedia/fixture/expected.tsv | 4 +- test/ballotpedia/BallotpediaTests.idric | 68 ++++ test/ballotpedia/check | 19 ++ 10 files changed, 571 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/ballotpedia.yml create mode 100644 ballotpedia.ipkg create mode 100644 test/ballotpedia/BallotpediaTests.idric create mode 100755 test/ballotpedia/check diff --git a/.github/workflows/ballotpedia.yml b/.github/workflows/ballotpedia.yml new file mode 100644 index 0000000..ffaf8ee --- /dev/null +++ b/.github/workflows/ballotpedia.yml @@ -0,0 +1,140 @@ +name: Ballotpedia through ICU + +on: + push: + branches: + - ballotpedia-cli + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + ballotpedia: + runs-on: ubuntu-24.04 + steps: + - name: Checkout Idriç CLI + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Checkout Idriç compiler boundary + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: isomorphisms/Idric + ref: a8baedff0a536376a3c1411d3d2d3f0bc0194eb5 + path: .tools/Idric + + - name: Checkout Idric-Net + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: isomorphisms/Idric-Net + ref: 3e7643c8d8dd2a940d5b6dc96403ddae765402cd + path: .tools/Idric-Net + + - name: Checkout checked ICU request boundary + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: dilapidated-shed/icu + ref: 2dd3b855786993feb662edfdd8d740f083f90c8d + path: .tools/icu + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends chezscheme libssl-dev + + - name: Build Idriç and install typed dependencies + env: + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + run: | + make -C .tools/Idric/_ bootstrap SCHEME=scheme + cd .tools/Idric-Net + ../Idric/_/build/exec/idris2 --install idric-net.ipkg + cd ../Idric/_/libs/contrib + ../../build/exec/idris2 --install contrib.ipkg + + - name: Build ICU and Ballotpedia + env: + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + run: | + make -C .tools/icu \ + IDRIC="$GITHUB_WORKSPACE/.tools/Idric/_/build/exec/idris2" + make ballotpedia-check \ + IDRIC="$GITHUB_WORKSPACE/.tools/Idric/_/build/exec/idris2" + + - name: Start deterministic Ballotpedia-shaped server + run: | + cat > "$RUNNER_TEMP/ballotpedia-mock.py" <<'PY' + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from pathlib import Path + import os + + fixture = Path(os.environ["GITHUB_WORKSPACE"]) / \ + "checkpoints/ballotpedia/fixture/elections_by_state.json" + body = fixture.read_bytes() + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, format, *args): + pass + + def do_GET(self): + expected = "/data/elections_by_state?state=MI&election_date=2026-11-03&page=1" + if self.path != expected: + self.send_error(400, "wrong request target") + return + if self.headers.get("x-api-key") != "test-key": + self.send_error(401, "wrong API key") + return + if self.headers.get("Accept") != "application/json": + self.send_error(400, "wrong Accept header") + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + ThreadingHTTPServer(("127.0.0.1", 18083), Handler).serve_forever() + PY + python3 "$RUNNER_TEMP/ballotpedia-mock.py" \ + > "$RUNNER_TEMP/ballotpedia-mock.log" 2>&1 & + echo $! > "$RUNNER_TEMP/ballotpedia-mock.pid" + sleep 1 + + - name: Prove request headers, captured body, typed JSON, and TSV + env: + BALLOTPEDIA_API_KEY: test-key + BALLOTPEDIA_API_BASE: http://127.0.0.1:18083/data/elections_by_state + ICU: ${{ github.workspace }}/.tools/icu/build/exec/icu + LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/icu + run: | + ./build/exec/ballotpedia elections MI 2026-11-03 \ + > "$RUNNER_TEMP/ballotpedia.tsv" + cmp checkpoints/ballotpedia/fixture/expected.tsv \ + "$RUNNER_TEMP/ballotpedia.tsv" + + if env -u BALLOTPEDIA_API_KEY \ + ./build/exec/ballotpedia elections MI 2026-11-03 \ + > "$RUNNER_TEMP/missing-key.out" \ + 2> "$RUNNER_TEMP/missing-key.err"; then + echo 'missing API key unexpectedly succeeded' >&2 + exit 1 + fi + test ! -s "$RUNNER_TEMP/missing-key.out" + grep -F 'missing BALLOTPEDIA_API_KEY' "$RUNNER_TEMP/missing-key.err" + + if BALLOTPEDIA_API_KEY= \ + ./build/exec/ballotpedia elections MI 2026-11-03 \ + > "$RUNNER_TEMP/empty-key.out" \ + 2> "$RUNNER_TEMP/empty-key.err"; then + echo 'empty API key unexpectedly succeeded' >&2 + exit 1 + fi + test ! -s "$RUNNER_TEMP/empty-key.out" + grep -F 'BALLOTPEDIA_API_KEY is empty' "$RUNNER_TEMP/empty-key.err" + + - name: Show mock log on failure + if: failure() + run: cat "$RUNNER_TEMP/ballotpedia-mock.log" 2>/dev/null || true diff --git a/Ballotpedia.idric b/Ballotpedia.idric index 920b9f0..44b8fd8 100644 --- a/Ballotpedia.idric +++ b/Ballotpedia.idric @@ -1,11 +1,11 @@ module Ballotpedia -import Network.HTTP -import Network.Transport +import Language.JSON import Network.URL import System -import Transport +import System.File +%default total api_base : String api_base = "https://api4.ballotpedia.org/data/elections_by_state" @@ -17,34 +17,34 @@ usage = " ballotpedia elections STATE YYYY-MM-DD\n" ++ "\nBALLOTPEDIA_API_KEY is required for elections." +public export choice command one_of show_url String String list_elections String String +public export record Candidate where - constructor make_candidate - candidate_id : String - person_id : String + constructor MakeCandidate + person_id : Maybe String name : String - party : String - office : String - district : String - profile_url : String + parties : List String + district : Maybe String + race : Maybe String --- These are narrow library/compiler watchpoints. The CLI stays Idriç-only: --- ICU owns HTTP; Ballotpedia owns the meaning of this request and response. -environment_value : String → IO (Maybe String) -environment_value name = ?read_environment_value +public export +choice ballotpedia_failure one_of + invalid_request String + missing_api_key + empty_api_key + network_failure Int + http_failure String + invalid_json + invalid_response String -icu_get_with_headers : List HttpHeader → url → IO (Either String String) -icu_get_with_headers headers target = ?icu_response_body_with_request_headers - -decode_candidates : String → Either String (List Candidate) -decode_candidates body = ?decode_ballotpedia_candidates - -clean_field : String → String -clean_field text = ?flatten_tsv_field +digit : Char → Bool +digit character = character >= '0' && character <= '9' +public export valid_state : String → Bool valid_state state = case unpack state of @@ -53,9 +53,7 @@ valid_state state = second >= 'A' && second <= 'Z' _ => False -digit : Char → Bool -digit character = character >= '0' && character <= '9' - +public export valid_date : String → Bool valid_date date = case unpack date of @@ -64,32 +62,182 @@ valid_date date = digit m1 && digit m2 && digit d1 && digit d2 _ => False +public export elections_url_text : String → String → String elections_url_text state date = - api_base ++ "?state=" ++ state ++ "&date=" ++ date + api_base ++ "?state=" ++ state ++ "&election_date=" ++ date ++ "&page=1" -parse_target : String → String → Either String url -parse_target state date = +elections_url_from_base : String → String → String → String +elections_url_from_base base state date = + base ++ "?state=" ++ state ++ "&election_date=" ++ date ++ "&page=1" + +parse_target_text : String → String → String → Either ballotpedia_failure url +parse_target_text target_text state date = if not (valid_state state) - then Left "ballotpedia: STATE must be two uppercase ASCII letters" + then Left (invalid_request "STATE must be two uppercase ASCII letters") else if not (valid_date date) - then Left "ballotpedia: date must have the form YYYY-MM-DD" - else parse_url (elections_url_text state date) + then Left (invalid_request "date must have the form YYYY-MM-DD") + else case parse_url target_text of + Left problem => Left (invalid_request problem) + Right target => Right target +public export +parse_target : String → String → Either ballotpedia_failure url +parse_target state date = + parse_target_text (elections_url_text state date) state date + +public export parse_command : List String → Either String command parse_command ["url", state, date] = Right (show_url state date) parse_command ["elections", state, date] = Right (list_elections state date) parse_command _ = Left usage +object_field : String → List (String, JSON) → Maybe JSON +object_field wanted [] = Nothing +object_field wanted ((field_name, value) :: rest) = + if wanted == field_name then Just value else object_field wanted rest + +required_object : String → List (String, JSON) → Either String (List (String, JSON)) +required_object field_name object = + case object_field field_name object of + Just (JObject value) => Right value + Just _ => Left (field_name ++ " is not an object") + Nothing => Left ("missing " ++ field_name) + +required_string : String → List (String, JSON) → Either String String +required_string field_name object = + case object_field field_name object of + Just (JString value) => Right value + Just _ => Left (field_name ++ " is not a string") + Nothing => Left ("missing " ++ field_name) + +optional_string : String → List (String, JSON) → Maybe String +optional_string field_name object = + case object_field field_name object of + Just (JString value) => Just value + _ => Nothing + +optional_identifier : String → List (String, JSON) → Maybe String +optional_identifier field_name object = + case object_field field_name object of + Just (JString value) => Just value + Just (JNumber value) => Just (show value) + _ => Nothing + +optional_array : String → List (String, JSON) → Either String (List JSON) +optional_array field_name object = + case object_field field_name object of + Nothing => Right [] + Just JNull => Right [] + Just (JArray values) => Right values + Just _ => Left (field_name ++ " is not an array") + +party_name : JSON → Either String String +party_name (JObject object) = required_string "name" object +party_name _ = Left "party affiliation is not an object" + +party_names : List JSON → Either String (List String) +party_names [] = Right [] +party_names (value :: rest) = do + name ← party_name value + remaining ← party_names rest + pure (name :: remaining) + +decode_candidate : Maybe String → Maybe String → JSON → Either String Candidate +decode_candidate district_name race_name (JObject object) = do + person ← required_object "person" object + candidate_name ← required_string "name" person + affiliations ← optional_array "party_affiliation" object + affiliation_names ← party_names affiliations + pure + (MakeCandidate + (optional_identifier "id" person) + candidate_name + affiliation_names + district_name + race_name) +decode_candidate _ _ _ = Left "candidate is not an object" + +decode_candidate_values : + Maybe String → Maybe String → List JSON → Either String (List Candidate) +decode_candidate_values _ _ [] = Right [] +decode_candidate_values district_name race_name (value :: rest) = do + candidate ← decode_candidate district_name race_name value + remaining ← decode_candidate_values district_name race_name rest + pure (candidate :: remaining) + +decode_race : Maybe String → JSON → Either String (List Candidate) +decode_race district_name (JObject object) = do + candidates ← optional_array "candidates" object + decode_candidate_values district_name (optional_string "name" object) candidates +decode_race _ _ = Left "race is not an object" + +decode_races : Maybe String → List JSON → Either String (List Candidate) +decode_races _ [] = Right [] +decode_races district_name (value :: rest) = do + candidates ← decode_race district_name value + remaining ← decode_races district_name rest + pure (candidates ++ remaining) + +decode_district : JSON → Either String (List Candidate) +decode_district (JObject object) = do + races ← optional_array "races" object + decode_races (optional_string "name" object) races +decode_district _ = Left "district is not an object" + +decode_districts : List JSON → Either String (List Candidate) +decode_districts [] = Right [] +decode_districts (value :: rest) = do + candidates ← decode_district value + remaining ← decode_districts rest + pure (candidates ++ remaining) + +decode_root : JSON → Either String (List Candidate) +decode_root (JObject root) = do + data_object ← required_object "data" root + case object_field "districts" data_object of + Just (JArray districts) => decode_districts districts + Just _ => Left "districts is not an array" + Nothing => Left "missing districts" +decode_root _ = Left "response root is not an object" + +public export +decode_candidates : String → Either ballotpedia_failure (List Candidate) +decode_candidates body = + case Language.JSON.parse body of + Nothing => Left invalid_json + Just value => + case decode_root value of + Left problem => Left (invalid_response problem) + Right candidates => Right candidates + +clean_character : Char → Char +clean_character '\t' = ' ' +clean_character '\n' = ' ' +clean_character '\r' = ' ' +clean_character character = character + +public export +clean_field : String → String +clean_field text = pack (map clean_character (unpack text)) + +join_with : String → List String → String +join_with _ [] = "" +join_with _ [value] = value +join_with separator (value :: rest) = value ++ separator ++ join_with separator rest + +show_optional : Maybe String → String +show_optional Nothing = "" +show_optional (Just value) = value + +public export render_candidate : Candidate → String render_candidate candidate = - clean_field candidate.candidate_id ++ "\t" ++ - clean_field candidate.person_id ++ "\t" ++ + clean_field (show_optional candidate.person_id) ++ "\t" ++ clean_field candidate.name ++ "\t" ++ - clean_field candidate.party ++ "\t" ++ - clean_field candidate.office ++ "\t" ++ - clean_field candidate.district ++ "\t" ++ - clean_field candidate.profile_url + clean_field (join_with "; " candidate.parties) ++ "\t" ++ + clean_field (show_optional candidate.district) ++ "\t" ++ + clean_field (show_optional candidate.race) print_candidates : List Candidate → IO () print_candidates [] = pure () @@ -97,37 +245,78 @@ print_candidates (candidate :: rest) = do putStrLn (render_candidate candidate) print_candidates rest +public export +icu_arguments : String → String → String → List String +icu_arguments executable api_key target_url = + [ executable + , "get" + , "-H" + , "x-api-key: " ++ api_key + , "-H" + , "Accept: application/json" + , target_url + ] + +covering +perform_elections : String → String → IO (Either ballotpedia_failure (List Candidate)) +perform_elections state date = do + maybe_base ← environment_value "BALLOTPEDIA_API_BASE" + let base = case maybe_base of + Just configured => configured + Nothing => api_base + let target_text = elections_url_from_base base state date + case parse_target_text target_text state date of + Left problem => pure (Left problem) + Right _ => do + maybe_key ← environment_value "BALLOTPEDIA_API_KEY" + case maybe_key of + Nothing => pure (Left missing_api_key) + Just "" => pure (Left empty_api_key) + Just api_key => do + maybe_icu ← environment_value "ICU" + let executable = case maybe_icu of + Just path => path + Nothing => "icu" + (body, status) ← run + (icu_arguments executable api_key target_text) + if status == 0 + then pure (decode_candidates body) + else if status == 10 + then pure (Left (http_failure body)) + else pure (Left (network_failure status)) + +public export +render_failure : ballotpedia_failure → String +render_failure (invalid_request problem) = "ballotpedia: invalid request: " ++ problem +render_failure missing_api_key = "ballotpedia: missing BALLOTPEDIA_API_KEY" +render_failure empty_api_key = "ballotpedia: BALLOTPEDIA_API_KEY is empty" +render_failure (network_failure status) = + "ballotpedia: ICU network/transport failure (exit " ++ show status ++ ")" +render_failure (http_failure _) = "ballotpedia: Ballotpedia returned a non-2xx HTTP response" +render_failure invalid_json = "ballotpedia: refused invalid JSON" +render_failure (invalid_response problem) = + "ballotpedia: refused unexpected response: " ++ problem + fail_with : String → IO () fail_with problem = do - putStrLn problem + _ ← fPutStrLn stderr problem exitFailure -run : command → IO () -run (show_url state date) = +covering +run_command : command → IO () +run_command (show_url state date) = case parse_target state date of - Left problem => fail_with problem + Left problem => fail_with (render_failure problem) Right _ => putStrLn (elections_url_text state date) +run_command (list_elections state date) = do + outcome ← perform_elections state date + case outcome of + Left problem => fail_with (render_failure problem) + Right candidates => do + putStrLn "person_id\tname\tparties\tdistrict\trace" + print_candidates candidates -run (list_elections state date) = - case parse_target state date of - Left problem => fail_with problem - Right target => do - maybe_key ← environment_value "BALLOTPEDIA_API_KEY" - case maybe_key of - Nothing => fail_with "ballotpedia: missing BALLOTPEDIA_API_KEY" - Just key => do - outcome ← icu_get_with_headers - (header "x-api-key" key :: header "Accept" "application/json" :: []) - target - case outcome of - Left problem => fail_with ("ballotpedia: " ++ problem) - Right body => - case decode_candidates body of - Left problem => fail_with ("ballotpedia: invalid response: " ++ problem) - Right candidates => do - putStrLn "candidate_id\tperson_id\tname\tparty\toffice\tdistrict\tprofile_url" - print_candidates candidates - +covering main : IO () main = do arguments ← getArgs @@ -135,5 +324,5 @@ main = do _ :: rest => case parse_command rest of Left problem => fail_with problem - Right value => run value + Right value => run_command value [] => fail_with usage diff --git a/Makefile b/Makefile index 418650d..f67ec75 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,20 @@ PREFIX ?= /usr/local DESTDIR ?= SHELL ?= /bin/sh +IDRIC ?= idris2 -.PHONY: test install +.PHONY: test ballotpedia ballotpedia-check install test: bash test/az-test.sh bash test/abe-test.sh +ballotpedia: + $(IDRIC) --build ballotpedia.ipkg + +ballotpedia-check: + sh test/ballotpedia/check + install: install -d "$(DESTDIR)$(PREFIX)/bin" install -m 0755 bin/az "$(DESTDIR)$(PREFIX)/bin/az" diff --git a/README.md b/README.md index 9a3e5d1..dd64aed 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This repository is the consolidation point for the CLI/API-access programs that - `bin/az` — Amazon product/price access and append-only price observations. - `bin/abe` — AbeBooks delivered-price lookup and Impact affiliate links. - `Ap.idric` — Associated Press API checkpoint. -- `Ballotpedia.idric` — Ballotpedia API4 elections-by-state client checkpoint, entirely in Idriç with ICU as its HTTP boundary. +- `Ballotpedia.idric` — executable Ballotpedia API4 page-one elections-by-state client, entirely in Idriç with ICU as its HTTP boundary. - `Economist.idric` — Economist API checkpoint. - `Ft.idric` — Financial Times API checkpoint. - `Guardian.idric` — Guardian API checkpoint. @@ -20,7 +20,7 @@ This repository is the consolidation point for the CLI/API-access programs that The important Idriç sources are exposed at the repository top level so they can be inspected without digging through directories. Most are symbolic links to canonical sources under `checkpoints/`; Ballotpedia's top-level file is currently its canonical source. -Some Idriç clients intentionally contain named holes for compiler/library boundaries that are not implemented yet. Keep those boundaries visible; do not make a client appear green by silently substituting another HTTP implementation. +Some Idriç clients intentionally contain named holes for compiler/library boundaries that are not implemented yet. Keep those boundaries visible; do not make a client appear green by silently substituting another HTTP implementation. Ballotpedia's former generic environment, header/body, and JSON holes have been replaced by executable boundaries and focused receipts; its remaining limits are recorded in `checkpoints/ballotpedia/README.md`. ## Networking @@ -28,6 +28,10 @@ Where these clients need networking, ICU/Idric-Net remains the intended transpor ## Tests -`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit and Ballotpedia have separate checkpoints under `checkpoints/`; they are not part of `make test` while named Idriç holes remain. +`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit retains a separate manual compiler checkpoint while named Idriç holes remain. Ballotpedia has a separate reproducible receipt: + +```text +make ballotpedia-check IDRIC=/opt/Idric/_/build/exec/idris2 +``` See `PROVENANCE.md` for the source branches copied into this repository. diff --git a/ballotpedia.ipkg b/ballotpedia.ipkg new file mode 100644 index 0000000..94ca0b3 --- /dev/null +++ b/ballotpedia.ipkg @@ -0,0 +1,9 @@ +package ballotpedia + +version = 0.1.0 +depends = contrib, idric_net +sourcedir = "." + +modules = Ballotpedia +main = Ballotpedia +executable = ballotpedia diff --git a/checkpoints/ballotpedia/README.md b/checkpoints/ballotpedia/README.md index c1c4510..8e3b866 100644 --- a/checkpoints/ballotpedia/README.md +++ b/checkpoints/ballotpedia/README.md @@ -14,23 +14,56 @@ header. Ownership stays explicit: ```text -arguments → Idriç validation → Ballotpedia request → ICU → HTTPS - → ICU response → Idriç decoding → typed candidates → TSV +arguments → Idriç validation → ICU command → HTTPS + → captured response body → Idriç JSON decoding → typed candidates → TSV ``` -## Current execution boundary +The request uses Ballotpedia's `election_date` parameter and explicitly asks +for page 1. The response decoder follows the available nested shape: -This is an honest compiler/library checkpoint, not a shell-backed client. -The checked ICU path currently accepts only its fixed request headers and -returns transport status while streaming the HTTP response. Therefore the -source retains three named holes: +```text +data → districts → races → candidates → person / party_affiliation +``` + +The earlier checkpoint's `date` parameter and flat `data.candidates` fixture +were not supported by the available client implementations and have been +removed. + +## Re-evaluated boundaries + +The three original named holes are no longer one undifferentiated block: + +- Idriç #64 supplies `environment_value`; the later source-layout change + accidentally dropped it from the buildable library tree, so the acceptance + workflow pins the narrow restoration until that regression is merged. +- ICU #13 accepts checked repeatable `-H` values, writes the response body to + stdout, and assigns distinct nonzero outcomes to HTTP and transport failure. + Ballotpedia invokes that command through `System.run`; there is no second + HTTP implementation here. +- `Language.JSON` performs the parse, and the Ballotpedia module projects the + nested fixture into a typed candidate record before rendering TSV. + +The remaining boundaries are specific rather than generic library holes: + +- page 1 is implemented; following every Ballotpedia page is not; +- the nested synthetic fixture covers person name/id, party affiliation, and + optional district/race names, but a captured live response fixture is still + needed before claiming the rest of Ballotpedia's paid response contract; +- no live receipt runs without an explicitly supplied Ballotpedia API key. + +`BALLOTPEDIA_API_BASE` exists for deterministic local receipts. Normal use +leaves it unset and uses `https://api4.ballotpedia.org/data/elections_by_state`. + +The request parameter, pagination, and `data.districts` envelope are also +consistent with the available Ballotpedia Python client implementation. A +separate public adapter corroborates the nested races, candidates, person, and +party-affiliation path. Neither source substitutes for a retained live fixture. + +The `url` command and deterministic fixture receipt do not need a key. The +production path remains Idriç plus ICU; curl, Python, JavaScript, and SDKs are +not fallback transports. -- environment lookup; -- ICU caller-supplied headers plus captured response body; -- Ballotpedia JSON decoding. +Sources used to correct the checkpoint: -The `url` command and request validation do not need a key and define the -first executable acceptance surface. Do not fill the holes with curl, -Python, JavaScript, or another HTTP implementation. The next real step is -to extend the typed ICU interface, then discharge the decoder against the -synthetic fixture in this directory. +- +- diff --git a/checkpoints/ballotpedia/fixture/elections_by_state.json b/checkpoints/ballotpedia/fixture/elections_by_state.json index c9cd3b0..8d1fd6a 100644 --- a/checkpoints/ballotpedia/fixture/elections_by_state.json +++ b/checkpoints/ballotpedia/fixture/elections_by_state.json @@ -1,14 +1,25 @@ { + "success": true, "data": { - "candidates": [ + "districts": [ { - "candidate_id": "100", - "person_id": "200", - "name": "Ada Example", - "party": "Independent", - "office": "City Council", - "district": "Ward 1", - "url": "https://ballotpedia.org/Ada_Example" + "name": "Ward 1", + "races": [ + { + "name": "City Council", + "candidates": [ + { + "person": { + "id": "200", + "name": "Ada Example" + }, + "party_affiliation": [ + { "name": "Independent" } + ] + } + ] + } + ] } ] } diff --git a/checkpoints/ballotpedia/fixture/expected.tsv b/checkpoints/ballotpedia/fixture/expected.tsv index 3bbabf9..93b29b6 100644 --- a/checkpoints/ballotpedia/fixture/expected.tsv +++ b/checkpoints/ballotpedia/fixture/expected.tsv @@ -1,2 +1,2 @@ -candidate_id person_id name party office district profile_url -100 200 Ada Example Independent City Council Ward 1 https://ballotpedia.org/Ada_Example +person_id name parties district race +200 Ada Example Independent Ward 1 City Council diff --git a/test/ballotpedia/BallotpediaTests.idric b/test/ballotpedia/BallotpediaTests.idric new file mode 100644 index 0000000..667e049 --- /dev/null +++ b/test/ballotpedia/BallotpediaTests.idric @@ -0,0 +1,68 @@ +module Main + +import Ballotpedia +import System +import System.File + +check : String → Bool → IO Bool +check name True = do + putStrLn ("PASS\t" ++ name) + pure True +check name False = do + putStrLn ("FAIL\t" ++ name) + pure False + +request_shape : Bool +request_shape = + elections_url_text "MI" "2026-11-03" == + "https://api4.ballotpedia.org/data/elections_by_state?state=MI&election_date=2026-11-03&page=1" + +header_shape : Bool +header_shape = + icu_arguments "icu" "test-key" "https://example.test/elections" == + [ "icu" + , "get" + , "-H" + , "x-api-key: test-key" + , "-H" + , "Accept: application/json" + , "https://example.test/elections" + ] + +decoded_shape : String → Bool +decoded_shape body = + case decode_candidates body of + Right [candidate] => + candidate.person_id == Just "200" && + candidate.name == "Ada Example" && + candidate.parties == ["Independent"] && + candidate.district == Just "Ward 1" && + candidate.race == Just "City Council" + _ => False + +invalid_json_refused : String → Bool +invalid_json_refused body = + case decode_candidates body of + Left invalid_json => True + _ => False + +wrong_envelope_refused : String → Bool +wrong_envelope_refused body = + case decode_candidates body of + Left (invalid_response _) => True + _ => False + +clean_tsv_field : Bool +clean_tsv_field = clean_field "one\ttwo\nthree\rfour" == "one two three four" + +main : IO () +main = do + Right fixture ← readFile "checkpoints/ballotpedia/fixture/elections_by_state.json" + | Left _ => exitFailure + one ← check "Ballotpedia request uses election_date and page" request_shape + two ← check "ICU owns checked caller headers" header_shape + three ← check "typed nested candidate decode" (decoded_shape fixture) + four ← check "invalid JSON refused" (invalid_json_refused "not-json") + five ← check "wrong response envelope refused" (wrong_envelope_refused "{\"data\":{}}") + six ← check "TSV separators flattened" clean_tsv_field + if one && two && three && four && five && six then pure () else exitFailure diff --git a/test/ballotpedia/check b/test/ballotpedia/check new file mode 100755 index 0000000..56beeee --- /dev/null +++ b/test/ballotpedia/check @@ -0,0 +1,19 @@ +#!/bin/sh + +set -u + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/../.." && pwd) +IDRIC=${IDRIC:-idris2} + +if ! command -v "$IDRIC" >/dev/null 2>&1 && ! test -x "$IDRIC"; then + printf 'SKIP\tballotpedia/idric (not found: %s)\n' "$IDRIC" + exit 0 +fi + +cd "$ROOT" || exit 1 + +"$IDRIC" --build ballotpedia.ipkg +IDRIS2_PATH="$ROOT" "$IDRIC" -p contrib -p idric_net \ + test/ballotpedia/BallotpediaTests.idric -o ballotpedia-tests +"$ROOT/build/exec/ballotpedia-tests" From b7f708be730be55174807b15ff0b3ed5c580c10b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 2 Sep 2026 14:21:42 -0400 Subject: [PATCH 7/7] Document remaining credential boundary --- checkpoints/ballotpedia/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/checkpoints/ballotpedia/README.md b/checkpoints/ballotpedia/README.md index 8e3b866..4a70507 100644 --- a/checkpoints/ballotpedia/README.md +++ b/checkpoints/ballotpedia/README.md @@ -45,6 +45,9 @@ The three original named holes are no longer one undifferentiated block: The remaining boundaries are specific rather than generic library holes: +- ICU's current executable interface carries caller header values in the + child-process argument vector; a non-argv credential channel is not yet + available to this CLI; - page 1 is implemented; following every Ballotpedia page is not; - the nested synthetic fixture covers person name/id, party affiliation, and optional district/race names, but a captured live response fixture is still