From b385a874e9fa6365a23f6c4c275ef081064eef9d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 2 Sep 2026 22:29:58 -0400 Subject: [PATCH 01/14] Begin Handshake CLI checkpoint --- Handshake.idric | 1 + README.md | 3 +- checkpoints/handshake/README.md | 109 +++++++++ checkpoints/handshake/check | 57 +++++ .../fixture/job_role_groups.expected.tsv | 2 + .../handshake/fixture/job_role_groups.json | 15 ++ .../handshake/fixture/jobs.expected.tsv | 2 + checkpoints/handshake/fixture/jobs.json | 26 +++ checkpoints/handshake/idric/Handshake.idric | 212 ++++++++++++++++++ 9 files changed, 426 insertions(+), 1 deletion(-) create mode 120000 Handshake.idric create mode 100644 checkpoints/handshake/README.md create mode 100755 checkpoints/handshake/check create mode 100644 checkpoints/handshake/fixture/job_role_groups.expected.tsv create mode 100644 checkpoints/handshake/fixture/job_role_groups.json create mode 100644 checkpoints/handshake/fixture/jobs.expected.tsv create mode 100644 checkpoints/handshake/fixture/jobs.json create mode 100644 checkpoints/handshake/idric/Handshake.idric diff --git a/Handshake.idric b/Handshake.idric new file mode 120000 index 0000000..f271826 --- /dev/null +++ b/Handshake.idric @@ -0,0 +1 @@ +checkpoints/handshake/idric/Handshake.idric \ No newline at end of file diff --git a/README.md b/README.md index abe7022..dd07f4c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This repository is the consolidation point for the CLI/API-access programs that - `Economist.idric` — Economist API checkpoint. - `Ft.idric` — Financial Times API checkpoint. - `Guardian.idric` — Guardian API checkpoint. +- `Handshake.idric` — Handshake EDU API checkpoint for jobs and job-role classifications, plus public job URLs. - `Nyt.idric` — New York Times API checkpoint. - `Reddit.idric` — Reddit Data API checkpoint, with a synthetic fixture and manual receipt. - `Reuters.idric` — Reuters GraphQL checkpoint. @@ -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 Handshake have separate manual compiler 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. diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md new file mode 100644 index 0000000..e031df3 --- /dev/null +++ b/checkpoints/handshake/README.md @@ -0,0 +1,109 @@ +# Handshake CLI compiler checkpoint + +This directory begins a Handshake command-line client in Idriç. + +The first slice is deliberately narrow: use Handshake's documented read-only EDU API for `jobs` and `job_role_groups`, keep the API-key/header boundary explicit, and expose public job URLs separately. Do not treat Handshake's authenticated student web application as a public API and do not make this checkpoint green by replaying browser cookies or substituting curl/Python. + +## Why these two endpoints first + +The immediate analytical use is to compare what a posting actually says with the occupational/job-role classification Handshake attaches to it. The official EDU API documents: + +- `/jobs` — job id, title, employer id, employment type, job type, pay rate, remote/on-site/hybrid flags, salary type, external URL, timestamps, and related fields; +- `/job_role_groups` — job id, Handshake job-role-group id, job-role-group name, and timestamps. + +Keeping those tables separate preserves Handshake's own data model. A later slice can join them by `job_id` and measure classification disagreements without silently rewriting the source data. + +Official documentation, checked 2026-09-02: + +- https://support.joinhandshake.com/hc/en-us/articles/31061076506391-Getting-Started-with-EDU-API +- https://support.joinhandshake.com/hc/en-us/articles/35762729693719-EDU-API-Endpoint-Definitions + +## Access boundary + +Handshake describes the EDU API as read-only and institution-scoped. Access requires an EDU API subscription approved by Handshake Support and requests use an `x-api-key` header. + +This checkpoint therefore expects: + +```text +HANDSHAKE_EDU_API_KEY=... +``` + +That credential is not assumed to exist. Missing credentials are a normal refusal case. + +The public job catalog is a separate surface. Public job detail pages currently use URLs such as: + +```text +https://app.joinhandshake.com/public/jobs/JOB_ID +``` + +`handshake public JOB_ID` only constructs that public URL. It does not claim that the public catalog has the same API contract as the institution-scoped EDU API. + +## Command contract + +```text +handshake url jobs +handshake url roles +handshake public JOB_ID +handshake fixture-jobs FILE +handshake fixture-roles FILE +handshake jobs +handshake roles +``` + +### `url jobs` / `url roles` + +No network. Print the documented EDU API endpoint with a page size of 100. + +### `public JOB_ID` + +No network. Validate a decimal Handshake job id and print its public job-detail URL. + +### `fixture-jobs` / `fixture-roles` + +No network. Decode a synthetic EDU-API-shaped JSON response and emit TSV. + +Committed fixtures: + +```text +fixture/jobs.json +fixture/jobs.expected.tsv +fixture/job_role_groups.json +fixture/job_role_groups.expected.tsv +``` + +### `jobs` / `roles` + +Read `HANDSHAKE_EDU_API_KEY`, perform one ICU/Idric-Net GET with the required `x-api-key` header, decode the response, and emit the same TSV shape as the fixtures. + +This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should be the next network-level checkpoint after a single live page works. + +## Named holes + +`idric/Handshake.idric` intentionally leaves the following visible: + +- TSV field cleanup; +- fixture file input; +- EDU jobs JSON decoding; +- EDU job-role-group JSON decoding; +- environment access; +- decimal job-id validation; +- ICU GET with caller-supplied `x-api-key` header. + +The transport hole belongs at the shared ICU/Idric-Net request-header boundary, not in a Handshake-specific HTTP implementation. + +## Checkpoint ladder + +1. source parses/checks; +2. `url jobs` and `url roles` print the documented endpoints; +3. `public JOB_ID` accepts decimal ids and rejects malformed ids; +4. jobs fixture decodes and matches its TSV receipt; +5. job-role-group fixture decodes and matches its TSV receipt; +6. the process environment distinguishes missing API key from an empty/present value; +7. one live `/jobs` page travels through ICU with `x-api-key`; +8. one live `/job_role_groups` page travels through the same boundary; +9. cursor pagination is added without changing the one-page decoder contract; +10. a separate analysis command joins jobs and role groups by `job_id` and reports classification evidence without altering raw observations. + +## Public-catalog follow-up + +The public `find-jobs` catalog is useful to an individual job seeker even without institutional EDU API credentials. It should be investigated as a separate checkpoint. Only add a machine-readable public-catalog client if there is a stable public interface whose use can be documented; do not infer a private student API from browser internals. diff --git a/checkpoints/handshake/check b/checkpoints/handshake/check new file mode 100755 index 0000000..287d6f1 --- /dev/null +++ b/checkpoints/handshake/check @@ -0,0 +1,57 @@ +#!/usr/bin/env ysh + +# Manual Idriç Handshake checkpoint runner. +# Deliberately not part of the root make test while named holes remain. + +set -u + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +IDRIC=${IDRIC:-idris2} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +status=0 + +available() { + case "$1" in + */*) test -x "$1" ;; + *) command -v "$1" >/dev/null 2>&1 ;; + esac +} + +pass() { + printf 'PASS\t%s\n' "$1" +} + +fail() { + printf 'FAIL\t%s\n' "$1" + status=1 +} + +skip() { + printf 'SKIP\t%s\n' "$1" +} + +show_diagnostics() { + for path in "$@"; do + if test -s "$path"; then + sed 's/^/ /' "$path" + fi + done +} + +if ! available "$IDRIC"; then + skip "idric (not found: $IDRIC)" + exit 0 +fi + +if "$IDRIC" --check "$HERE/idric/Handshake.idric" \ + >"$TMP/idric" 2>"$TMP/idric.err"; then + pass 'idric/check' +else + fail 'idric/check' + show_diagnostics "$TMP/idric" "$TMP/idric.err" +fi + +exit "$status" diff --git a/checkpoints/handshake/fixture/job_role_groups.expected.tsv b/checkpoints/handshake/fixture/job_role_groups.expected.tsv new file mode 100644 index 0000000..7f41131 --- /dev/null +++ b/checkpoints/handshake/fixture/job_role_groups.expected.tsv @@ -0,0 +1,2 @@ +job_id job_role_group_id job_role_group_name updated_at +1001 3001 Synthetic Model-Training Role 2026-09-01T12:00:00Z diff --git a/checkpoints/handshake/fixture/job_role_groups.json b/checkpoints/handshake/fixture/job_role_groups.json new file mode 100644 index 0000000..5496a96 --- /dev/null +++ b/checkpoints/handshake/fixture/job_role_groups.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "job_id": 1001, + "job_role_group_id": 3001, + "job_role_group_name": "Synthetic Model-Training Role", + "created_at": "2026-08-31T12:00:00Z", + "updated_at": "2026-09-01T12:00:00Z" + } + ], + "meta": { + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/checkpoints/handshake/fixture/jobs.expected.tsv b/checkpoints/handshake/fixture/jobs.expected.tsv new file mode 100644 index 0000000..282a7d6 --- /dev/null +++ b/checkpoints/handshake/fixture/jobs.expected.tsv @@ -0,0 +1,2 @@ +id title employer_id employment_type type pay_rate remote on_site hybrid salary_type external_url updated_at +1001 Synthetic Remote Research Task 2001 part-time Job $45/hour True False False paid 2026-09-01T12:00:00Z diff --git a/checkpoints/handshake/fixture/jobs.json b/checkpoints/handshake/fixture/jobs.json new file mode 100644 index 0000000..ed3136e --- /dev/null +++ b/checkpoints/handshake/fixture/jobs.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 1001, + "title": "Synthetic Remote Research Task", + "employer_id": 2001, + "start_date_time": "2026-09-01T00:00:00Z", + "employment_type": "part-time", + "type": "Job", + "pay_rate": "$45/hour", + "remote": true, + "external_url": null, + "on_site": false, + "hybrid": false, + "archived": false, + "duration": "Temporary/Seasonal", + "salary_type": "paid", + "created_at": "2026-08-31T12:00:00Z", + "updated_at": "2026-09-01T12:00:00Z" + } + ], + "meta": { + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric new file mode 100644 index 0000000..bef2a54 --- /dev/null +++ b/checkpoints/handshake/idric/Handshake.idric @@ -0,0 +1,212 @@ +module Handshake + +import System + + +edu_base : String +edu_base = "https://edu-api.joinhandshake.com/v1" + +public_job_base : String +public_job_base = "https://app.joinhandshake.com/public/jobs/" + +jobs_header : String +jobs_header = "id\ttitle\temployer_id\temployment_type\ttype\tpay_rate\tremote\ton_site\thybrid\tsalary_type\texternal_url\tupdated_at" + +roles_header : String +roles_header = "job_id\tjob_role_group_id\tjob_role_group_name\tupdated_at" + +usage : String +usage = "usage: handshake {url jobs | url roles | public JOB_ID | fixture-jobs FILE | fixture-roles FILE | jobs | roles}" + + +choice endpoint one_of + jobs_endpoint + roles_endpoint + +choice command one_of + show_url endpoint + public_job String + fixture_jobs String + fixture_roles String + fetch_jobs + fetch_roles + + +record Job where + constructor make_job + id : Integer + title : String + employer_id : Integer + employment_type : Maybe String + job_type : Maybe String + pay_rate : Maybe String + remote : Bool + on_site : Bool + hybrid : Bool + salary_type : Maybe String + external_url : Maybe String + updated_at : String + +record Job_Role_Group where + constructor make_job_role_group + job_id : Integer + job_role_group_id : Integer + job_role_group_name : String + updated_at : String + + +-- Named holes are intentional compiler/library watchpoints. Do not replace +-- them with curl, Python, browser-cookie replay, or another hidden HTTP path. + +clean_field : String → String +clean_field text = ?flatten_tsv_field + +read_fixture : String → IO (Either String String) +read_fixture path = ?read_fixture_text + +decode_jobs_response : String → Either String (List Job) +decode_jobs_response body = ?decode_handshake_jobs_response + +decode_roles_response : String → Either String (List Job_Role_Group) +decode_roles_response body = ?decode_handshake_job_role_groups_response + +environment_value : String → IO (Maybe String) +environment_value name = ?read_environment_value + +valid_job_id : String → Bool +valid_job_id text = ?decimal_job_id_only + +-- The documented EDU API uses an x-api-key request header. ICU/Idric-Net +-- remains the transport boundary; caller-supplied headers belong there. +edu_icu_get : String → String → IO (Either String String) +edu_icu_get api_key url = ?icu_get_with_x_api_key + + +endpoint_url : endpoint → String +endpoint_url jobs_endpoint = edu_base ++ "/jobs?page_size=100" +endpoint_url roles_endpoint = edu_base ++ "/job_role_groups?page_size=100" + + +parse_command : List String → Either String command +parse_command ["url", "jobs"] = Right (show_url jobs_endpoint) +parse_command ["url", "roles"] = Right (show_url roles_endpoint) +parse_command ["public", job_id] = Right (public_job job_id) +parse_command ["fixture-jobs", path] = Right (fixture_jobs path) +parse_command ["fixture-roles", path] = Right (fixture_roles path) +parse_command ["jobs"] = Right fetch_jobs +parse_command ["roles"] = Right fetch_roles +parse_command _ = Left usage + + +maybe_field : Maybe String → String +maybe_field Nothing = "" +maybe_field (Just text) = clean_field text + +render_job : Job → String +render_job job = + show job.id ++ "\t" ++ + clean_field job.title ++ "\t" ++ + show job.employer_id ++ "\t" ++ + maybe_field job.employment_type ++ "\t" ++ + maybe_field job.job_type ++ "\t" ++ + maybe_field job.pay_rate ++ "\t" ++ + show job.remote ++ "\t" ++ + show job.on_site ++ "\t" ++ + show job.hybrid ++ "\t" ++ + maybe_field job.salary_type ++ "\t" ++ + maybe_field job.external_url ++ "\t" ++ + clean_field job.updated_at + +render_role : Job_Role_Group → String +render_role role = + show role.job_id ++ "\t" ++ + show role.job_role_group_id ++ "\t" ++ + clean_field role.job_role_group_name ++ "\t" ++ + clean_field role.updated_at + +print_jobs_rows : List Job → IO () +print_jobs_rows [] = pure () +print_jobs_rows (job :: rest) = do + putStrLn (render_job job) + print_jobs_rows rest + +print_role_rows : List Job_Role_Group → IO () +print_role_rows [] = pure () +print_role_rows (role :: rest) = do + putStrLn (render_role role) + print_role_rows rest + +print_jobs_response : String → IO () +print_jobs_response body = + case decode_jobs_response body of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right jobs => do + putStrLn jobs_header + print_jobs_rows jobs + +print_roles_response : String → IO () +print_roles_response body = + case decode_roles_response body of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right roles => do + putStrLn roles_header + print_role_rows roles + +read_and_print : (String → IO ()) → String → IO () +read_and_print printer path = do + outcome ← read_fixture path + case outcome of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right body => printer body + +fetch_and_print : (String → IO ()) → String → IO () +fetch_and_print printer url = do + maybe_key ← environment_value "HANDSHAKE_EDU_API_KEY" + case maybe_key of + Nothing => do + putStrLn "handshake: missing HANDSHAKE_EDU_API_KEY" + exitFailure + Just api_key => do + outcome ← edu_icu_get api_key url + case outcome of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right body => printer body + + +run : command → IO () +run (show_url which) = putStrLn (endpoint_url which) + +run (public_job job_id) = + if valid_job_id job_id + then putStrLn (public_job_base ++ job_id) + else do + putStrLn "handshake: JOB_ID must contain decimal digits only" + exitFailure + +run (fixture_jobs path) = read_and_print print_jobs_response path +run (fixture_roles path) = read_and_print print_roles_response path +run fetch_jobs = fetch_and_print print_jobs_response (endpoint_url jobs_endpoint) +run fetch_roles = fetch_and_print print_roles_response (endpoint_url roles_endpoint) + + +main : IO () +main = do + arguments ← getArgs + case arguments of + _ :: rest => + case parse_command rest of + Left problem => do + putStrLn problem + exitFailure + Right value => run value + [] => do + putStrLn usage + exitFailure From d794904de6b7576c3c03d67187f511db1395a8c8 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:00:41 -0400 Subject: [PATCH 02/14] Implement Handshake offline surfaces and isolate transport gap --- checkpoints/handshake/idric/Handshake.idric | 436 +++++++++++++++++++- 1 file changed, 425 insertions(+), 11 deletions(-) diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric index bef2a54..bb0c0d4 100644 --- a/checkpoints/handshake/idric/Handshake.idric +++ b/checkpoints/handshake/idric/Handshake.idric @@ -1,6 +1,8 @@ module Handshake +import Data.String import System +import System.File edu_base : String @@ -55,31 +57,440 @@ record Job_Role_Group where updated_at : String --- Named holes are intentional compiler/library watchpoints. Do not replace --- them with curl, Python, browser-cookie replay, or another hidden HTTP path. +-- This checkpoint needs only a small JSON subset, but parsing it structurally +-- keeps fields attached to the correct object instead of searching raw text. +choice json_value one_of + json_null + json_boolean Bool + json_integer Integer + json_number + json_string String + json_array (List json_value) + json_object (List (String, json_value)) + + +clean_field_chars : List Char → List Char +clean_field_chars [] = [] +clean_field_chars (value :: rest) = + if value == '\t' || value == '\n' || value == '\r' + then ' ' :: clean_field_chars rest + else value :: clean_field_chars rest clean_field : String → String -clean_field text = ?flatten_tsv_field +clean_field text = pack (clean_field_chars (unpack text)) + read_fixture : String → IO (Either String String) -read_fixture path = ?read_fixture_text +read_fixture path = do + outcome ← readFile path + case outcome of + Left problem => + pure (Left ("could not read fixture " ++ path ++ ": " ++ show problem)) + Right body => pure (Right body) + + +hex_value : Char → Maybe Int +hex_value value = + if value >= '0' && value <= '9' + then Just (ord value - ord '0') + else if value >= 'a' && value <= 'f' + then Just (10 + ord value - ord 'a') + else if value >= 'A' && value <= 'F' + then Just (10 + ord value - ord 'A') + else Nothing + +four_hex : List Char → Maybe (Int, List Char) +four_hex (a :: b :: c :: d :: rest) = + case hex_value a of + Nothing => Nothing + Just av => + case hex_value b of + Nothing => Nothing + Just bv => + case hex_value c of + Nothing => Nothing + Just cv => + case hex_value d of + Nothing => Nothing + Just dv => Just (av * 4096 + bv * 256 + cv * 16 + dv, rest) +four_hex _ = Nothing + +unicode_escape : List Char → Maybe (Char, List Char) +unicode_escape input = + case four_hex input of + Nothing => Nothing + Just (high, rest) => + if high >= 55296 && high <= 56319 + then case rest of + '\\' :: 'u' :: low_input => + case four_hex low_input of + Nothing => Nothing + Just (low, remaining) => + if low >= 56320 && low <= 57343 + then Just + (chr (65536 + (high - 55296) * 1024 + (low - 56320)), + remaining) + else Nothing + _ => Nothing + else if high >= 56320 && high <= 57343 + then Nothing + else Just (chr high, rest) + +parse_json_string_chars : List Char → List Char → Maybe (String, List Char) +parse_json_string_chars acc [] = Nothing +parse_json_string_chars acc ('"' :: rest) = Just (pack (reverse acc), rest) +parse_json_string_chars acc ('\\' :: '"' :: rest) = + parse_json_string_chars ('"' :: acc) rest +parse_json_string_chars acc ('\\' :: '\\' :: rest) = + parse_json_string_chars ('\\' :: acc) rest +parse_json_string_chars acc ('\\' :: '/' :: rest) = + parse_json_string_chars ('/' :: acc) rest +parse_json_string_chars acc ('\\' :: 'b' :: rest) = + parse_json_string_chars (chr 8 :: acc) rest +parse_json_string_chars acc ('\\' :: 'f' :: rest) = + parse_json_string_chars (chr 12 :: acc) rest +parse_json_string_chars acc ('\\' :: 'n' :: rest) = + parse_json_string_chars ('\n' :: acc) rest +parse_json_string_chars acc ('\\' :: 'r' :: rest) = + parse_json_string_chars ('\r' :: acc) rest +parse_json_string_chars acc ('\\' :: 't' :: rest) = + parse_json_string_chars ('\t' :: acc) rest +parse_json_string_chars acc ('\\' :: 'u' :: rest) = + case unicode_escape rest of + Nothing => Nothing + Just (value, remaining) => parse_json_string_chars (value :: acc) remaining +parse_json_string_chars acc ('\\' :: _ :: _) = Nothing +parse_json_string_chars acc (value :: rest) = + if ord value < 32 + then Nothing + else parse_json_string_chars (value :: acc) rest + +parse_json_string : List Char → Maybe (String, List Char) +parse_json_string ('"' :: rest) = parse_json_string_chars [] rest +parse_json_string _ = Nothing + +json_space : Char → Bool +json_space value = + value == ' ' || value == '\n' || value == '\r' || value == '\t' + +skip_json_space : List Char → List Char +skip_json_space [] = [] +skip_json_space input@(value :: rest) = + if json_space value then skip_json_space rest else input + +decimal_digit : Char → Bool +decimal_digit value = value >= '0' && value <= '9' + +consume_digits : Integer → List Char → (Integer, List Char) +consume_digits total [] = (total, []) +consume_digits total input@(value :: rest) = + if decimal_digit value + then + consume_digits + (total * 10 + cast (ord value - ord '0')) + rest + else (total, input) + +consume_digit_tail : List Char → List Char +consume_digit_tail [] = [] +consume_digit_tail input@(value :: rest) = + if decimal_digit value then consume_digit_tail rest else input + +json_number_after_exponent : List Char → Either String (json_value, List Char) +json_number_after_exponent ('+' :: value :: rest) = + if decimal_digit value + then Right (json_number, consume_digit_tail rest) + else Left "JSON exponent requires decimal digits" +json_number_after_exponent ('-' :: value :: rest) = + if decimal_digit value + then Right (json_number, consume_digit_tail rest) + else Left "JSON exponent requires decimal digits" +json_number_after_exponent (value :: rest) = + if decimal_digit value + then Right (json_number, consume_digit_tail rest) + else Left "JSON exponent requires decimal digits" +json_number_after_exponent [] = Left "JSON exponent requires decimal digits" + +json_number_after_integer : + Integer → + List Char → + Either String (json_value, List Char) +json_number_after_integer number ('.' :: value :: rest) = + if decimal_digit value + then + case consume_digit_tail rest of + 'e' :: exponent_rest => json_number_after_exponent exponent_rest + 'E' :: exponent_rest => json_number_after_exponent exponent_rest + remaining => Right (json_number, remaining) + else Left "JSON fraction requires decimal digits" +json_number_after_integer number ('e' :: rest) = + json_number_after_exponent rest +json_number_after_integer number ('E' :: rest) = + json_number_after_exponent rest +json_number_after_integer number rest = Right (json_integer number, rest) + +parse_json_number : List Char → Either String (json_value, List Char) +parse_json_number ('-' :: value :: rest) = + if decimal_digit value + then + let (number, remaining) = + consume_digits (cast (ord value - ord '0')) rest + in json_number_after_integer (0 - number) remaining + else Left "expected decimal digit after JSON minus sign" +parse_json_number (value :: rest) = + if decimal_digit value + then + let (number, remaining) = + consume_digits (cast (ord value - ord '0')) rest + in json_number_after_integer number remaining + else Left "expected JSON number" +parse_json_number [] = Left "expected JSON number" + +mutual + parse_json_value : List Char → Either String (json_value, List Char) + parse_json_value raw_input = + case skip_json_space raw_input of + [] => Left "unexpected end of JSON" + 'n' :: 'u' :: 'l' :: 'l' :: rest => + Right (json_null, rest) + 't' :: 'r' :: 'u' :: 'e' :: rest => + Right (json_boolean True, rest) + 'f' :: 'a' :: 'l' :: 's' :: 'e' :: rest => + Right (json_boolean False, rest) + input@('"' :: _) => + case parse_json_string input of + Nothing => Left "invalid JSON string" + Just (text, rest) => Right (json_string text, rest) + input@('[' :: _) => parse_json_array input + input@('{' :: _) => parse_json_object input + input@('-' :: _) => parse_json_number input + input@(value :: _) => + if decimal_digit value + then parse_json_number input + else Left "unsupported or malformed JSON value" + + parse_json_array : List Char → Either String (json_value, List Char) + parse_json_array ('[' :: rest) = + parse_json_array_items [] (skip_json_space rest) + parse_json_array _ = Left "expected JSON array" + + parse_json_array_items : + List json_value → + List Char → + Either String (json_value, List Char) + parse_json_array_items acc [] = Left "unterminated JSON array" + parse_json_array_items acc (']' :: rest) = + Right (json_array (reverse acc), rest) + parse_json_array_items acc input = do + (value, after_value) ← parse_json_value input + case skip_json_space after_value of + ',' :: rest => + parse_json_array_items (value :: acc) (skip_json_space rest) + ']' :: rest => + Right (json_array (reverse (value :: acc)), rest) + _ => Left "expected comma or closing bracket in JSON array" + + parse_json_object : List Char → Either String (json_value, List Char) + parse_json_object ('{' :: rest) = + parse_json_object_fields [] (skip_json_space rest) + parse_json_object _ = Left "expected JSON object" + + parse_json_object_fields : + List (String, json_value) → + List Char → + Either String (json_value, List Char) + parse_json_object_fields acc [] = Left "unterminated JSON object" + parse_json_object_fields acc ('}' :: rest) = + Right (json_object (reverse acc), rest) + parse_json_object_fields acc input = + case parse_json_string input of + Nothing => Left "expected string field name in JSON object" + Just (name, after_name) => + case skip_json_space after_name of + ':' :: after_colon => do + (value, after_value) ← parse_json_value after_colon + case skip_json_space after_value of + ',' :: rest => + parse_json_object_fields + ((name, value) :: acc) + (skip_json_space rest) + '}' :: rest => + Right (json_object (reverse ((name, value) :: acc)), rest) + _ => Left "expected comma or closing brace in JSON object" + _ => Left "expected colon after JSON field name" + + +parse_json_document : String → Either String json_value +parse_json_document body = do + (value, remaining) ← parse_json_value (unpack body) + case skip_json_space remaining of + [] => Right value + _ => Left "trailing content after JSON document" + + +lookup_json_field : + String → + List (String, json_value) → + Maybe json_value +lookup_json_field wanted [] = Nothing +lookup_json_field wanted ((name, value) :: rest) = + if name == wanted + then Just value + else lookup_json_field wanted rest + +required_integer : + String → + List (String, json_value) → + Either String Integer +required_integer name fields = + case lookup_json_field name fields of + Just (json_integer value) => Right value + Just _ => Left ("field " ++ name ++ " must be an integer") + Nothing => Left ("missing field " ++ name) + +required_string : + String → + List (String, json_value) → + Either String String +required_string name fields = + case lookup_json_field name fields of + Just (json_string value) => Right value + Just _ => Left ("field " ++ name ++ " must be a string") + Nothing => Left ("missing field " ++ name) + +required_boolean : + String → + List (String, json_value) → + Either String Bool +required_boolean name fields = + case lookup_json_field name fields of + Just (json_boolean value) => Right value + Just _ => Left ("field " ++ name ++ " must be a boolean") + Nothing => Left ("missing field " ++ name) + +optional_string : + String → + List (String, json_value) → + Either String (Maybe String) +optional_string name fields = + case lookup_json_field name fields of + Nothing => Right Nothing + Just json_null => Right Nothing + Just (json_string value) => Right (Just value) + Just _ => Left ("field " ++ name ++ " must be a string or null") + +response_data : json_value → Either String (List json_value) +response_data (json_object fields) = + case lookup_json_field "data" fields of + Just (json_array values) => Right values + Just _ => Left "field data must be an array" + Nothing => Left "missing field data" +response_data _ = Left "Handshake response must be a JSON object" + + +decode_job : json_value → Either String Job +decode_job (json_object fields) = do + identifier ← required_integer "id" fields + title ← required_string "title" fields + employer_identifier ← required_integer "employer_id" fields + employment ← optional_string "employment_type" fields + kind ← optional_string "type" fields + pay ← optional_string "pay_rate" fields + is_remote ← required_boolean "remote" fields + is_on_site ← required_boolean "on_site" fields + is_hybrid ← required_boolean "hybrid" fields + salary ← optional_string "salary_type" fields + external ← optional_string "external_url" fields + updated ← required_string "updated_at" fields + pure + (make_job + identifier + title + employer_identifier + employment + kind + pay + is_remote + is_on_site + is_hybrid + salary + external + updated) +decode_job _ = Left "each data item in /jobs must be a JSON object" + +decode_jobs_values : List json_value → Either String (List Job) +decode_jobs_values [] = Right [] +decode_jobs_values (value :: rest) = do + job ← decode_job value + jobs ← decode_jobs_values rest + pure (job :: jobs) decode_jobs_response : String → Either String (List Job) -decode_jobs_response body = ?decode_handshake_jobs_response +decode_jobs_response body = do + document ← parse_json_document body + values ← response_data document + decode_jobs_values values + + +decode_role : json_value → Either String Job_Role_Group +decode_role (json_object fields) = do + job_identifier ← required_integer "job_id" fields + role_identifier ← required_integer "job_role_group_id" fields + role_name ← required_string "job_role_group_name" fields + updated ← required_string "updated_at" fields + pure + (make_job_role_group + job_identifier + role_identifier + role_name + updated) +decode_role _ = + Left "each data item in /job_role_groups must be a JSON object" + +decode_roles_values : + List json_value → + Either String (List Job_Role_Group) +decode_roles_values [] = Right [] +decode_roles_values (value :: rest) = do + role ← decode_role value + roles ← decode_roles_values rest + pure (role :: roles) decode_roles_response : String → Either String (List Job_Role_Group) -decode_roles_response body = ?decode_handshake_job_role_groups_response +decode_roles_response body = do + document ← parse_json_document body + values ← response_data document + decode_roles_values values + +-- System.getEnv already has exactly the Maybe-valued lookup needed here. +-- Idriç's stricter environment_value wrapper is being restored separately, +-- but this client asks only for one fixed, valid environment-variable name. environment_value : String → IO (Maybe String) -environment_value name = ?read_environment_value +environment_value name = getEnv name + + +decimal_digits : List Char → Bool +decimal_digits [] = True +decimal_digits (value :: rest) = + decimal_digit value && decimal_digits rest valid_job_id : String → Bool -valid_job_id text = ?decimal_job_id_only +valid_job_id text = + case unpack text of + [] => False + values => decimal_digits values --- The documented EDU API uses an x-api-key request header. ICU/Idric-Net --- remains the transport boundary; caller-supplied headers belong there. + +-- Genuine remaining dependency boundary: +-- current merged ICU/Idric-Net cannot both attach a caller-supplied x-api-key +-- header and return the response body to this caller. Fail closed until that +-- shared transport surface exists; do not substitute curl/Python/browser state. edu_icu_get : String → String → IO (Either String String) -edu_icu_get api_key url = ?icu_get_with_x_api_key +edu_icu_get api_key url = + pure + (Left + "live EDU GET pending ICU/Idric-Net caller-header + response-body API") endpoint_url : endpoint → String @@ -172,6 +583,9 @@ fetch_and_print printer url = do Nothing => do putStrLn "handshake: missing HANDSHAKE_EDU_API_KEY" exitFailure + Just "" => do + putStrLn "handshake: HANDSHAKE_EDU_API_KEY is empty" + exitFailure Just api_key => do outcome ← edu_icu_get api_key url case outcome of From 7175b42468369dd98b0cc2145892a5e1ca7d6e86 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:01:23 -0400 Subject: [PATCH 03/14] Document resolved Handshake surfaces and true transport dependency --- checkpoints/handshake/README.md | 56 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md index e031df3..67cf7c9 100644 --- a/checkpoints/handshake/README.md +++ b/checkpoints/handshake/README.md @@ -1,8 +1,8 @@ # Handshake CLI compiler checkpoint -This directory begins a Handshake command-line client in Idriç. +This directory is a Handshake EDU API command-line client checkpoint in Idriç. -The first slice is deliberately narrow: use Handshake's documented read-only EDU API for `jobs` and `job_role_groups`, keep the API-key/header boundary explicit, and expose public job URLs separately. Do not treat Handshake's authenticated student web application as a public API and do not make this checkpoint green by replaying browser cookies or substituting curl/Python. +The slice is deliberately narrow: use Handshake's documented read-only EDU API for `jobs` and `job_role_groups`, keep the API-key/header boundary explicit, and expose public job URLs separately. Do not treat Handshake's authenticated student web application as a public API and do not make this checkpoint green by replaying browser cookies or substituting curl/Python. ## Why these two endpoints first @@ -28,7 +28,7 @@ This checkpoint therefore expects: HANDSHAKE_EDU_API_KEY=... ``` -That credential is not assumed to exist. Missing credentials are a normal refusal case. +That credential is not assumed to exist. Missing and empty credentials are explicit refusal cases. The public job catalog is a separate surface. Public job detail pages currently use URLs such as: @@ -56,7 +56,7 @@ No network. Print the documented EDU API endpoint with a page size of 100. ### `public JOB_ID` -No network. Validate a decimal Handshake job id and print its public job-detail URL. +No network. Validate a nonempty ASCII decimal Handshake job id and print its public job-detail URL. ### `fixture-jobs` / `fixture-roles` @@ -75,21 +75,38 @@ fixture/job_role_groups.expected.tsv Read `HANDSHAKE_EDU_API_KEY`, perform one ICU/Idric-Net GET with the required `x-api-key` header, decode the response, and emit the same TSV shape as the fixtures. +The live transport call currently fails closed because the merged ICU/Idric-Net surface cannot yet both attach caller-supplied headers and return the response body to the Idriç caller. + This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should be the next network-level checkpoint after a single live page works. -## Named holes +## Surface audit + +The original draft made seven named holes. Six do not require a new shared dependency and are now implemented locally or through existing Idriç library surfaces: + +| Original hole | Present surface | Current treatment | +| --- | --- | --- | +| TSV field cleanup | `String`, `pack`, `unpack` | local replacement of tab/newline/carriage-return with spaces | +| fixture input | `System.File.readFile : ... → Either FileError String` | implemented with explicit file-error text | +| jobs JSON decoding | ordinary Idriç data/recursion | focused structural JSON parser + typed `/jobs` decoder | +| role-group JSON decoding | same | focused structural JSON parser + typed `/job_role_groups` decoder | +| environment access | `System.getEnv : ... → Maybe String` | direct wrapper; fixed `HANDSHAKE_EDU_API_KEY` name | +| decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | +| ICU GET with `x-api-key` | incomplete on merged transport path | remains the one genuine dependency/API gap | + +The local JSON parser handles strings (including JSON escapes and surrogate pairs), null, booleans, integers, other JSON-number forms, arrays, and objects. Only the fields needed for the two raw tables are decoded into Handshake records; unrelated response fields are parsed and ignored rather than searched textually. + +Idriç PR #67 is separately restoring the stricter project-level `environment_value : String → IO (Maybe String)` wrapper after the source-layout rewrite. That wrapper rejects invalid environment-variable names and preserves unset versus empty. Handshake does not need to block on it: this client queries one fixed valid name and current `System.getEnv` already returns `Maybe String`. + +## Genuine remaining transport gap + +The current merged Idric-Net HTTP model already has typed HTTP header values, but its normal renderer supplies a fixed header set. Current merged ICU's `send_request` consumes that rendered request and returns a transport result; it does not expose the response body to this caller. -`idric/Handshake.idric` intentionally leaves the following visible: +Two pending ICU lines demonstrate the missing halves, but neither is the merged shared API Handshake needs: -- TSV field cleanup; -- fixture file input; -- EDU jobs JSON decoding; -- EDU job-role-group JSON decoding; -- environment access; -- decimal job-id validation; -- ICU GET with caller-supplied `x-api-key` header. +- ICU #13 adds checked caller-supplied request headers, including `-H` / `--header`, on its pending branch; +- ICU #12 exposes a file-backed raw response capture seam for the OpenAI client on another pending stack. -The transport hole belongs at the shared ICU/Idric-Net request-header boundary, not in a Handshake-specific HTTP implementation. +Handshake therefore does **not** invent a private transport. `edu_icu_get` currently returns an explicit `Left` explaining that caller-header + response-body support is pending. Live `jobs` and `roles` fail closed there. Once ICU/Idric-Net exposes one current shared request API with both capabilities, that small boundary can be replaced directly. ## Checkpoint ladder @@ -98,11 +115,12 @@ The transport hole belongs at the shared ICU/Idric-Net request-header boundary, 3. `public JOB_ID` accepts decimal ids and rejects malformed ids; 4. jobs fixture decodes and matches its TSV receipt; 5. job-role-group fixture decodes and matches its TSV receipt; -6. the process environment distinguishes missing API key from an empty/present value; -7. one live `/jobs` page travels through ICU with `x-api-key`; -8. one live `/job_role_groups` page travels through the same boundary; -9. cursor pagination is added without changing the one-page decoder contract; -10. a separate analysis command joins jobs and role groups by `job_id` and reports classification evidence without altering raw observations. +6. process environment distinguishes missing and empty API keys; +7. shared ICU/Idric-Net request API accepts caller headers and returns a response body; +8. one live `/jobs` page travels through that API with `x-api-key`; +9. one live `/job_role_groups` page travels through the same boundary; +10. cursor pagination is added without changing the one-page decoder contract; +11. a separate analysis command joins jobs and role groups by `job_id` and reports classification evidence without altering raw observations. ## Public-catalog follow-up From 793f63a770a4858b307529146789e1936abbe3af Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:03:26 -0400 Subject: [PATCH 04/14] Exercise Handshake offline checkpoint end to end --- checkpoints/handshake/check | 121 +++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/checkpoints/handshake/check b/checkpoints/handshake/check index 287d6f1..e1ec5bc 100755 --- a/checkpoints/handshake/check +++ b/checkpoints/handshake/check @@ -1,7 +1,8 @@ #!/usr/bin/env ysh -# Manual Idriç Handshake checkpoint runner. -# Deliberately not part of the root make test while named holes remain. +# Idriç Handshake checkpoint runner. +# The offline surfaces are executable; the live network command must fail +# closed at the one documented ICU/Idric-Net dependency boundary. set -u @@ -41,6 +42,46 @@ show_diagnostics() { done } +expect_success_output() { + label=$1 + expected=$2 + shift 2 + + if "$@" >"$TMP/actual" 2>"$TMP/actual.err"; then + printf '%s\n' "$expected" >"$TMP/expected" + if cmp -s "$TMP/expected" "$TMP/actual"; then + pass "$label" + else + fail "$label" + diff -u "$TMP/expected" "$TMP/actual" || true + show_diagnostics "$TMP/actual.err" + fi + else + fail "$label" + show_diagnostics "$TMP/actual" "$TMP/actual.err" + fi +} + +expect_failure_output() { + label=$1 + expected=$2 + shift 2 + + if "$@" >"$TMP/actual" 2>"$TMP/actual.err"; then + fail "$label" + show_diagnostics "$TMP/actual" "$TMP/actual.err" + else + printf '%s\n' "$expected" >"$TMP/expected" + if cmp -s "$TMP/expected" "$TMP/actual"; then + pass "$label" + else + fail "$label" + diff -u "$TMP/expected" "$TMP/actual" || true + show_diagnostics "$TMP/actual.err" + fi + fi +} + if ! available "$IDRIC"; then skip "idric (not found: $IDRIC)" exit 0 @@ -52,6 +93,82 @@ if "$IDRIC" --check "$HERE/idric/Handshake.idric" \ else fail 'idric/check' show_diagnostics "$TMP/idric" "$TMP/idric.err" + exit "$status" +fi + +if ( + cd "$TMP" + "$IDRIC" "$HERE/idric/Handshake.idric" -o handshake \ + >"$TMP/build.log" 2>"$TMP/build.err" +); then + pass 'idric/build' +else + fail 'idric/build' + show_diagnostics "$TMP/build.log" "$TMP/build.err" + exit "$status" +fi + +PROGRAM="$TMP/build/exec/handshake" +if test ! -x "$PROGRAM"; then + fail 'idric/executable' + find "$TMP" -maxdepth 4 -type f -print | sed 's/^/ /' + exit "$status" fi +pass 'idric/executable' + +expect_success_output \ + 'url/jobs' \ + 'https://edu-api.joinhandshake.com/v1/jobs?page_size=100' \ + "$PROGRAM" url jobs + +expect_success_output \ + 'url/roles' \ + 'https://edu-api.joinhandshake.com/v1/job_role_groups?page_size=100' \ + "$PROGRAM" url roles + +expect_success_output \ + 'public/decimal' \ + 'https://app.joinhandshake.com/public/jobs/123456' \ + "$PROGRAM" public 123456 + +expect_failure_output \ + 'public/reject-nondecimal' \ + 'handshake: JOB_ID must contain decimal digits only' \ + "$PROGRAM" public 12x + +if "$PROGRAM" fixture-jobs "$HERE/fixture/jobs.json" \ + >"$TMP/jobs.tsv" 2>"$TMP/jobs.err" && + cmp -s "$HERE/fixture/jobs.expected.tsv" "$TMP/jobs.tsv"; then + pass 'fixture/jobs' +else + fail 'fixture/jobs' + diff -u "$HERE/fixture/jobs.expected.tsv" "$TMP/jobs.tsv" || true + show_diagnostics "$TMP/jobs.err" +fi + +if "$PROGRAM" fixture-roles "$HERE/fixture/job_role_groups.json" \ + >"$TMP/roles.tsv" 2>"$TMP/roles.err" && + cmp -s "$HERE/fixture/job_role_groups.expected.tsv" "$TMP/roles.tsv"; then + pass 'fixture/roles' +else + fail 'fixture/roles' + diff -u "$HERE/fixture/job_role_groups.expected.tsv" "$TMP/roles.tsv" || true + show_diagnostics "$TMP/roles.err" +fi + +expect_failure_output \ + 'environment/missing' \ + 'handshake: missing HANDSHAKE_EDU_API_KEY' \ + env -u HANDSHAKE_EDU_API_KEY "$PROGRAM" jobs + +expect_failure_output \ + 'environment/empty' \ + 'handshake: HANDSHAKE_EDU_API_KEY is empty' \ + env HANDSHAKE_EDU_API_KEY= "$PROGRAM" jobs + +expect_failure_output \ + 'transport/explicit-gap' \ + 'handshake: live EDU GET pending ICU/Idric-Net caller-header + response-body API' \ + env HANDSHAKE_EDU_API_KEY=synthetic "$PROGRAM" jobs exit "$status" From 545d9866f71121fee70855067adcf9824d153a0c Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:03:46 -0400 Subject: [PATCH 05/14] Add current-Idric Handshake acceptance workflow --- .github/workflows/handshake-idric.yml | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/handshake-idric.yml diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml new file mode 100644 index 0000000..00ce1d0 --- /dev/null +++ b/.github/workflows/handshake-idric.yml @@ -0,0 +1,42 @@ +name: Handshake Idric checkpoint + +on: + pull_request: + paths: + - 'checkpoints/handshake/**' + - '.github/workflows/handshake-idric.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + handshake: + runs-on: ubuntu-latest + steps: + - name: Checkout idric-cli + uses: actions/checkout@v4 + + - name: Checkout current Idric + uses: actions/checkout@v4 + with: + repository: isomorphisms/Idric + ref: Idriç + path: .tools/Idric + + - name: Install host build tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential curl + + - name: Bootstrap Idric + run: .tools/Idric/_/edric bootstrap + + - name: Expose pinned Chez + run: echo "$GITHUB_WORKSPACE/.tools/Idric/_/.tools/bin" >> "$GITHUB_PATH" + + - name: Run Handshake checkpoint + env: + IDRIC: ${{ github.workspace }}/.tools/Idric/_/build/exec/idris2 + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + run: bash checkpoints/handshake/check From ffff423e84fa2124935f512ef589d44f66185613 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:17:57 -0400 Subject: [PATCH 06/14] Use Idric contrib JSON surface in Handshake checkpoint --- checkpoints/handshake/idric/Handshake.idric | 407 ++++---------------- 1 file changed, 73 insertions(+), 334 deletions(-) diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric index bb0c0d4..5f459eb 100644 --- a/checkpoints/handshake/idric/Handshake.idric +++ b/checkpoints/handshake/idric/Handshake.idric @@ -1,6 +1,7 @@ module Handshake import Data.String +import Language.JSON import System import System.File @@ -35,7 +36,7 @@ choice command one_of record Job where - constructor make_job + constructor Make_Job id : Integer title : String employer_id : Integer @@ -50,25 +51,13 @@ record Job where updated_at : String record Job_Role_Group where - constructor make_job_role_group + constructor Make_Job_Role_Group job_id : Integer job_role_group_id : Integer job_role_group_name : String updated_at : String --- This checkpoint needs only a small JSON subset, but parsing it structurally --- keeps fields attached to the correct object instead of searching raw text. -choice json_value one_of - json_null - json_boolean Bool - json_integer Integer - json_number - json_string String - json_array (List json_value) - json_object (List (String, json_value)) - - clean_field_chars : List Char → List Char clean_field_chars [] = [] clean_field_chars (value :: rest) = @@ -89,321 +78,66 @@ read_fixture path = do Right body => pure (Right body) -hex_value : Char → Maybe Int -hex_value value = - if value >= '0' && value <= '9' - then Just (ord value - ord '0') - else if value >= 'a' && value <= 'f' - then Just (10 + ord value - ord 'a') - else if value >= 'A' && value <= 'F' - then Just (10 + ord value - ord 'A') - else Nothing - -four_hex : List Char → Maybe (Int, List Char) -four_hex (a :: b :: c :: d :: rest) = - case hex_value a of - Nothing => Nothing - Just av => - case hex_value b of - Nothing => Nothing - Just bv => - case hex_value c of - Nothing => Nothing - Just cv => - case hex_value d of - Nothing => Nothing - Just dv => Just (av * 4096 + bv * 256 + cv * 16 + dv, rest) -four_hex _ = Nothing - -unicode_escape : List Char → Maybe (Char, List Char) -unicode_escape input = - case four_hex input of - Nothing => Nothing - Just (high, rest) => - if high >= 55296 && high <= 56319 - then case rest of - '\\' :: 'u' :: low_input => - case four_hex low_input of - Nothing => Nothing - Just (low, remaining) => - if low >= 56320 && low <= 57343 - then Just - (chr (65536 + (high - 55296) * 1024 + (low - 56320)), - remaining) - else Nothing - _ => Nothing - else if high >= 56320 && high <= 57343 - then Nothing - else Just (chr high, rest) - -parse_json_string_chars : List Char → List Char → Maybe (String, List Char) -parse_json_string_chars acc [] = Nothing -parse_json_string_chars acc ('"' :: rest) = Just (pack (reverse acc), rest) -parse_json_string_chars acc ('\\' :: '"' :: rest) = - parse_json_string_chars ('"' :: acc) rest -parse_json_string_chars acc ('\\' :: '\\' :: rest) = - parse_json_string_chars ('\\' :: acc) rest -parse_json_string_chars acc ('\\' :: '/' :: rest) = - parse_json_string_chars ('/' :: acc) rest -parse_json_string_chars acc ('\\' :: 'b' :: rest) = - parse_json_string_chars (chr 8 :: acc) rest -parse_json_string_chars acc ('\\' :: 'f' :: rest) = - parse_json_string_chars (chr 12 :: acc) rest -parse_json_string_chars acc ('\\' :: 'n' :: rest) = - parse_json_string_chars ('\n' :: acc) rest -parse_json_string_chars acc ('\\' :: 'r' :: rest) = - parse_json_string_chars ('\r' :: acc) rest -parse_json_string_chars acc ('\\' :: 't' :: rest) = - parse_json_string_chars ('\t' :: acc) rest -parse_json_string_chars acc ('\\' :: 'u' :: rest) = - case unicode_escape rest of - Nothing => Nothing - Just (value, remaining) => parse_json_string_chars (value :: acc) remaining -parse_json_string_chars acc ('\\' :: _ :: _) = Nothing -parse_json_string_chars acc (value :: rest) = - if ord value < 32 - then Nothing - else parse_json_string_chars (value :: acc) rest - -parse_json_string : List Char → Maybe (String, List Char) -parse_json_string ('"' :: rest) = parse_json_string_chars [] rest -parse_json_string _ = Nothing - -json_space : Char → Bool -json_space value = - value == ' ' || value == '\n' || value == '\r' || value == '\t' - -skip_json_space : List Char → List Char -skip_json_space [] = [] -skip_json_space input@(value :: rest) = - if json_space value then skip_json_space rest else input - -decimal_digit : Char → Bool -decimal_digit value = value >= '0' && value <= '9' +json_field : String → JSON → Maybe JSON +json_field name object = Language.JSON.lookup name object -consume_digits : Integer → List Char → (Integer, List Char) -consume_digits total [] = (total, []) -consume_digits total input@(value :: rest) = - if decimal_digit value - then - consume_digits - (total * 10 + cast (ord value - ord '0')) - rest - else (total, input) - -consume_digit_tail : List Char → List Char -consume_digit_tail [] = [] -consume_digit_tail input@(value :: rest) = - if decimal_digit value then consume_digit_tail rest else input - -json_number_after_exponent : List Char → Either String (json_value, List Char) -json_number_after_exponent ('+' :: value :: rest) = - if decimal_digit value - then Right (json_number, consume_digit_tail rest) - else Left "JSON exponent requires decimal digits" -json_number_after_exponent ('-' :: value :: rest) = - if decimal_digit value - then Right (json_number, consume_digit_tail rest) - else Left "JSON exponent requires decimal digits" -json_number_after_exponent (value :: rest) = - if decimal_digit value - then Right (json_number, consume_digit_tail rest) - else Left "JSON exponent requires decimal digits" -json_number_after_exponent [] = Left "JSON exponent requires decimal digits" - -json_number_after_integer : - Integer → - List Char → - Either String (json_value, List Char) -json_number_after_integer number ('.' :: value :: rest) = - if decimal_digit value - then - case consume_digit_tail rest of - 'e' :: exponent_rest => json_number_after_exponent exponent_rest - 'E' :: exponent_rest => json_number_after_exponent exponent_rest - remaining => Right (json_number, remaining) - else Left "JSON fraction requires decimal digits" -json_number_after_integer number ('e' :: rest) = - json_number_after_exponent rest -json_number_after_integer number ('E' :: rest) = - json_number_after_exponent rest -json_number_after_integer number rest = Right (json_integer number, rest) - -parse_json_number : List Char → Either String (json_value, List Char) -parse_json_number ('-' :: value :: rest) = - if decimal_digit value - then - let (number, remaining) = - consume_digits (cast (ord value - ord '0')) rest - in json_number_after_integer (0 - number) remaining - else Left "expected decimal digit after JSON minus sign" -parse_json_number (value :: rest) = - if decimal_digit value - then - let (number, remaining) = - consume_digits (cast (ord value - ord '0')) rest - in json_number_after_integer number remaining - else Left "expected JSON number" -parse_json_number [] = Left "expected JSON number" - -mutual - parse_json_value : List Char → Either String (json_value, List Char) - parse_json_value raw_input = - case skip_json_space raw_input of - [] => Left "unexpected end of JSON" - 'n' :: 'u' :: 'l' :: 'l' :: rest => - Right (json_null, rest) - 't' :: 'r' :: 'u' :: 'e' :: rest => - Right (json_boolean True, rest) - 'f' :: 'a' :: 'l' :: 's' :: 'e' :: rest => - Right (json_boolean False, rest) - input@('"' :: _) => - case parse_json_string input of - Nothing => Left "invalid JSON string" - Just (text, rest) => Right (json_string text, rest) - input@('[' :: _) => parse_json_array input - input@('{' :: _) => parse_json_object input - input@('-' :: _) => parse_json_number input - input@(value :: _) => - if decimal_digit value - then parse_json_number input - else Left "unsupported or malformed JSON value" - - parse_json_array : List Char → Either String (json_value, List Char) - parse_json_array ('[' :: rest) = - parse_json_array_items [] (skip_json_space rest) - parse_json_array _ = Left "expected JSON array" - - parse_json_array_items : - List json_value → - List Char → - Either String (json_value, List Char) - parse_json_array_items acc [] = Left "unterminated JSON array" - parse_json_array_items acc (']' :: rest) = - Right (json_array (reverse acc), rest) - parse_json_array_items acc input = do - (value, after_value) ← parse_json_value input - case skip_json_space after_value of - ',' :: rest => - parse_json_array_items (value :: acc) (skip_json_space rest) - ']' :: rest => - Right (json_array (reverse (value :: acc)), rest) - _ => Left "expected comma or closing bracket in JSON array" - - parse_json_object : List Char → Either String (json_value, List Char) - parse_json_object ('{' :: rest) = - parse_json_object_fields [] (skip_json_space rest) - parse_json_object _ = Left "expected JSON object" - - parse_json_object_fields : - List (String, json_value) → - List Char → - Either String (json_value, List Char) - parse_json_object_fields acc [] = Left "unterminated JSON object" - parse_json_object_fields acc ('}' :: rest) = - Right (json_object (reverse acc), rest) - parse_json_object_fields acc input = - case parse_json_string input of - Nothing => Left "expected string field name in JSON object" - Just (name, after_name) => - case skip_json_space after_name of - ':' :: after_colon => do - (value, after_value) ← parse_json_value after_colon - case skip_json_space after_value of - ',' :: rest => - parse_json_object_fields - ((name, value) :: acc) - (skip_json_space rest) - '}' :: rest => - Right (json_object (reverse ((name, value) :: acc)), rest) - _ => Left "expected comma or closing brace in JSON object" - _ => Left "expected colon after JSON field name" - - -parse_json_document : String → Either String json_value -parse_json_document body = do - (value, remaining) ← parse_json_value (unpack body) - case skip_json_space remaining of - [] => Right value - _ => Left "trailing content after JSON document" - - -lookup_json_field : - String → - List (String, json_value) → - Maybe json_value -lookup_json_field wanted [] = Nothing -lookup_json_field wanted ((name, value) :: rest) = - if name == wanted - then Just value - else lookup_json_field wanted rest - -required_integer : - String → - List (String, json_value) → - Either String Integer -required_integer name fields = - case lookup_json_field name fields of - Just (json_integer value) => Right value +required_integer : String → JSON → Either String Integer +required_integer name object = + case json_field name object of + Just (JNumber value) => + let integer : Integer = cast value + in if cast integer == value + then Right integer + else Left ("field " ++ name ++ " must be an integer") Just _ => Left ("field " ++ name ++ " must be an integer") Nothing => Left ("missing field " ++ name) -required_string : - String → - List (String, json_value) → - Either String String -required_string name fields = - case lookup_json_field name fields of - Just (json_string value) => Right value +required_string : String → JSON → Either String String +required_string name object = + case json_field name object of + Just (JString value) => Right value Just _ => Left ("field " ++ name ++ " must be a string") Nothing => Left ("missing field " ++ name) -required_boolean : - String → - List (String, json_value) → - Either String Bool -required_boolean name fields = - case lookup_json_field name fields of - Just (json_boolean value) => Right value +required_boolean : String → JSON → Either String Bool +required_boolean name object = + case json_field name object of + Just (JBoolean value) => Right value Just _ => Left ("field " ++ name ++ " must be a boolean") Nothing => Left ("missing field " ++ name) -optional_string : - String → - List (String, json_value) → - Either String (Maybe String) -optional_string name fields = - case lookup_json_field name fields of +optional_string : String → JSON → Either String (Maybe String) +optional_string name object = + case json_field name object of Nothing => Right Nothing - Just json_null => Right Nothing - Just (json_string value) => Right (Just value) + Just JNull => Right Nothing + Just (JString value) => Right (Just value) Just _ => Left ("field " ++ name ++ " must be a string or null") -response_data : json_value → Either String (List json_value) -response_data (json_object fields) = - case lookup_json_field "data" fields of - Just (json_array values) => Right values +response_data : JSON → Either String (List JSON) +response_data document = + case json_field "data" document of + Just (JArray values) => Right values Just _ => Left "field data must be an array" Nothing => Left "missing field data" -response_data _ = Left "Handshake response must be a JSON object" - - -decode_job : json_value → Either String Job -decode_job (json_object fields) = do - identifier ← required_integer "id" fields - title ← required_string "title" fields - employer_identifier ← required_integer "employer_id" fields - employment ← optional_string "employment_type" fields - kind ← optional_string "type" fields - pay ← optional_string "pay_rate" fields - is_remote ← required_boolean "remote" fields - is_on_site ← required_boolean "on_site" fields - is_hybrid ← required_boolean "hybrid" fields - salary ← optional_string "salary_type" fields - external ← optional_string "external_url" fields - updated ← required_string "updated_at" fields + + +decode_job : JSON → Either String Job +decode_job object@(JObject _) = do + identifier ← required_integer "id" object + title ← required_string "title" object + employer_identifier ← required_integer "employer_id" object + employment ← optional_string "employment_type" object + kind ← optional_string "type" object + pay ← optional_string "pay_rate" object + is_remote ← required_boolean "remote" object + is_on_site ← required_boolean "on_site" object + is_hybrid ← required_boolean "hybrid" object + salary ← optional_string "salary_type" object + external ← optional_string "external_url" object + updated ← required_string "updated_at" object pure - (make_job + (Make_Job identifier title employer_identifier @@ -418,7 +152,7 @@ decode_job (json_object fields) = do updated) decode_job _ = Left "each data item in /jobs must be a JSON object" -decode_jobs_values : List json_value → Either String (List Job) +decode_jobs_values : List JSON → Either String (List Job) decode_jobs_values [] = Right [] decode_jobs_values (value :: rest) = do job ← decode_job value @@ -426,20 +160,22 @@ decode_jobs_values (value :: rest) = do pure (job :: jobs) decode_jobs_response : String → Either String (List Job) -decode_jobs_response body = do - document ← parse_json_document body - values ← response_data document - decode_jobs_values values - - -decode_role : json_value → Either String Job_Role_Group -decode_role (json_object fields) = do - job_identifier ← required_integer "job_id" fields - role_identifier ← required_integer "job_role_group_id" fields - role_name ← required_string "job_role_group_name" fields - updated ← required_string "updated_at" fields +decode_jobs_response body = + case Language.JSON.parse body of + Nothing => Left "invalid JSON response" + Just document => do + values ← response_data document + decode_jobs_values values + + +decode_role : JSON → Either String Job_Role_Group +decode_role object@(JObject _) = do + job_identifier ← required_integer "job_id" object + role_identifier ← required_integer "job_role_group_id" object + role_name ← required_string "job_role_group_name" object + updated ← required_string "updated_at" object pure - (make_job_role_group + (Make_Job_Role_Group job_identifier role_identifier role_name @@ -447,9 +183,7 @@ decode_role (json_object fields) = do decode_role _ = Left "each data item in /job_role_groups must be a JSON object" -decode_roles_values : - List json_value → - Either String (List Job_Role_Group) +decode_roles_values : List JSON → Either String (List Job_Role_Group) decode_roles_values [] = Right [] decode_roles_values (value :: rest) = do role ← decode_role value @@ -457,10 +191,12 @@ decode_roles_values (value :: rest) = do pure (role :: roles) decode_roles_response : String → Either String (List Job_Role_Group) -decode_roles_response body = do - document ← parse_json_document body - values ← response_data document - decode_roles_values values +decode_roles_response body = + case Language.JSON.parse body of + Nothing => Left "invalid JSON response" + Just document => do + values ← response_data document + decode_roles_values values -- System.getEnv already has exactly the Maybe-valued lookup needed here. @@ -470,6 +206,9 @@ environment_value : String → IO (Maybe String) environment_value name = getEnv name +decimal_digit : Char → Bool +decimal_digit value = value >= '0' && value <= '9' + decimal_digits : List Char → Bool decimal_digits [] = True decimal_digits (value :: rest) = From e017ef233db964f31599c223e8c23b2b004a580d Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:18:08 -0400 Subject: [PATCH 07/14] Expose current Idric contrib modules to Handshake check --- .github/workflows/handshake-idric.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml index 00ce1d0..26c5c9f 100644 --- a/.github/workflows/handshake-idric.yml +++ b/.github/workflows/handshake-idric.yml @@ -39,4 +39,5 @@ jobs: env: IDRIC: ${{ github.workspace }}/.tools/Idric/_/build/exec/idris2 IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + IDRIS2_PATH: ${{ github.workspace }}/.tools/Idric/_/libs/contrib/build/ttc run: bash checkpoints/handshake/check From 8f842f65b3099c3faeea9d1990cc98f3eee4a069 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:18:48 -0400 Subject: [PATCH 08/14] Document existing Idric JSON surface for Handshake --- checkpoints/handshake/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md index 67cf7c9..abf8f99 100644 --- a/checkpoints/handshake/README.md +++ b/checkpoints/handshake/README.md @@ -81,19 +81,21 @@ This first slice intentionally does not paginate. Handshake documents cursor pag ## Surface audit -The original draft made seven named holes. Six do not require a new shared dependency and are now implemented locally or through existing Idriç library surfaces: +The original draft made seven named holes. Six do not require a new shared dependency and are now implemented through current Idriç surfaces or small client-local logic: | Original hole | Present surface | Current treatment | | --- | --- | --- | | TSV field cleanup | `String`, `pack`, `unpack` | local replacement of tab/newline/carriage-return with spaces | | fixture input | `System.File.readFile : ... → Either FileError String` | implemented with explicit file-error text | -| jobs JSON decoding | ordinary Idriç data/recursion | focused structural JSON parser + typed `/jobs` decoder | -| role-group JSON decoding | same | focused structural JSON parser + typed `/job_role_groups` decoder | +| jobs JSON decoding | `Language.JSON.parse`, `JSON`, `lookup` | typed `/jobs` decoder over Idriç contrib JSON | +| role-group JSON decoding | same | typed `/job_role_groups` decoder over the same parsed JSON | | environment access | `System.getEnv : ... → Maybe String` | direct wrapper; fixed `HANDSHAKE_EDU_API_KEY` name | | decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | | ICU GET with `x-api-key` | incomplete on merged transport path | remains the one genuine dependency/API gap | -The local JSON parser handles strings (including JSON escapes and surrogate pairs), null, booleans, integers, other JSON-number forms, arrays, and objects. Only the fields needed for the two raw tables are decoded into Handshake records; unrelated response fields are parsed and ignored rather than searched textually. +`Language.JSON` is already part of current Idriç contrib. It parses a string to `Maybe JSON`, with structural `JNull`, `JBoolean`, `JNumber`, `JString`, `JArray`, and `JObject` values plus object-field lookup. The Handshake checkpoint therefore does not carry its own JSON grammar. Only fields needed for the two raw tables are decoded into Handshake records; unrelated response fields remain parsed JSON and are ignored. + +`Language.JSON` represents JSON numbers as `Double`. Required Handshake identifier fields are accepted only when the parsed number converts back to the same integral value. If Handshake ever documents or emits identifiers outside the exactly representable integer range of that JSON surface, that becomes a real decoding-surface limitation rather than something this client should hide. Idriç PR #67 is separately restoring the stricter project-level `environment_value : String → IO (Maybe String)` wrapper after the source-layout rewrite. That wrapper rejects invalid environment-variable names and preserves unset versus empty. Handshake does not need to block on it: this client queries one fixed valid name and current `System.getEnv` already returns `Maybe String`. @@ -110,7 +112,7 @@ Handshake therefore does **not** invent a private transport. `edu_icu_get` curre ## Checkpoint ladder -1. source parses/checks; +1. source parses/checks against current Idriç plus contrib; 2. `url jobs` and `url roles` print the documented endpoints; 3. `public JOB_ID` accepts decimal ids and rejects malformed ids; 4. jobs fixture decodes and matches its TSV receipt; From a3fbc21ca4ad395932706e1d3fa0b6e881df9065 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:30:36 -0400 Subject: [PATCH 09/14] Check Handshake from an isolated module root --- checkpoints/handshake/check | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/checkpoints/handshake/check b/checkpoints/handshake/check index e1ec5bc..ed950f5 100755 --- a/checkpoints/handshake/check +++ b/checkpoints/handshake/check @@ -87,8 +87,16 @@ if ! available "$IDRIC"; then exit 0 fi -if "$IDRIC" --check "$HERE/idric/Handshake.idric" \ - >"$TMP/idric" 2>"$TMP/idric.err"; then +# Current Idriç checks the declared module name against the source path. Copy +# the exact checkpoint source into an isolated module root so `module Handshake` +# is checked as Handshake.idric rather than as a nested absolute path. +cp "$HERE/idric/Handshake.idric" "$TMP/Handshake.idric" + +if ( + cd "$TMP" + "$IDRIC" --check Handshake.idric \ + >"$TMP/idric" 2>"$TMP/idric.err" +); then pass 'idric/check' else fail 'idric/check' @@ -98,7 +106,7 @@ fi if ( cd "$TMP" - "$IDRIC" "$HERE/idric/Handshake.idric" -o handshake \ + "$IDRIC" Handshake.idric -o handshake \ >"$TMP/build.log" 2>"$TMP/build.err" ); then pass 'idric/build' @@ -168,7 +176,7 @@ expect_failure_output \ expect_failure_output \ 'transport/explicit-gap' \ - 'handshake: live EDU GET pending ICU/Idric-Net caller-header + response-body API' \ + 'handshake: live EDU GET pending ICU credential-safe x-api-key redirect policy (icu#19)' \ env HANDSHAKE_EDU_API_KEY=synthetic "$PROGRAM" jobs exit "$status" From 446826529899df792a33f7cb76e0a8abb05f3bd2 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:31:05 -0400 Subject: [PATCH 10/14] Name the genuine Handshake ICU credential gap --- checkpoints/handshake/idric/Handshake.idric | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric index 5f459eb..5af68ee 100644 --- a/checkpoints/handshake/idric/Handshake.idric +++ b/checkpoints/handshake/idric/Handshake.idric @@ -222,14 +222,16 @@ valid_job_id text = -- Genuine remaining dependency boundary: --- current merged ICU/Idric-Net cannot both attach a caller-supplied x-api-key --- header and return the response body to this caller. Fail closed until that --- shared transport surface exists; do not substitute curl/Python/browser state. +-- merged ICU/Idric-Net does not yet expose the complete caller-header + body +-- capture path. Pending ICU #13 stacked on #12 has those mechanics, but its +-- cross-origin redirect policy strips only Authorization/Cookie credentials; +-- x-api-key remains an ordinary header. Fail closed until callers can mark an +-- arbitrary header credential-sensitive (ICU #19). edu_icu_get : String → String → IO (Either String String) edu_icu_get api_key url = pure (Left - "live EDU GET pending ICU/Idric-Net caller-header + response-body API") + "live EDU GET pending ICU credential-safe x-api-key redirect policy (icu#19)") endpoint_url : endpoint → String From 3997cbe655e1dc694e47ed52359683457fd9af04 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:31:17 -0400 Subject: [PATCH 11/14] Exercise Handshake TSV field cleanup in fixture --- checkpoints/handshake/fixture/jobs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpoints/handshake/fixture/jobs.json b/checkpoints/handshake/fixture/jobs.json index ed3136e..ea6e146 100644 --- a/checkpoints/handshake/fixture/jobs.json +++ b/checkpoints/handshake/fixture/jobs.json @@ -2,7 +2,7 @@ "data": [ { "id": 1001, - "title": "Synthetic Remote Research Task", + "title": "Synthetic\tRemote\nResearch Task", "employer_id": 2001, "start_date_time": "2026-09-01T00:00:00Z", "employment_type": "part-time", From 4c470323c6dbe9871967ef34ff9537b2b3b6996a Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:31:41 -0400 Subject: [PATCH 12/14] Document Handshake ICU credential-policy gap precisely --- checkpoints/handshake/README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md index abf8f99..defd06a 100644 --- a/checkpoints/handshake/README.md +++ b/checkpoints/handshake/README.md @@ -71,11 +71,13 @@ fixture/job_role_groups.json fixture/job_role_groups.expected.tsv ``` +The jobs fixture includes escaped tab/newline characters in a title while its expected TSV contains spaces, so the receipt exercises field cleanup as well as JSON decoding. + ### `jobs` / `roles` Read `HANDSHAKE_EDU_API_KEY`, perform one ICU/Idric-Net GET with the required `x-api-key` header, decode the response, and emit the same TSV shape as the fixtures. -The live transport call currently fails closed because the merged ICU/Idric-Net surface cannot yet both attach caller-supplied headers and return the response body to the Idriç caller. +The live transport call currently fails closed at the credential-policy boundary described below. This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should be the next network-level checkpoint after a single live page works. @@ -91,7 +93,7 @@ The original draft made seven named holes. Six do not require a new shared depen | role-group JSON decoding | same | typed `/job_role_groups` decoder over the same parsed JSON | | environment access | `System.getEnv : ... → Maybe String` | direct wrapper; fixed `HANDSHAKE_EDU_API_KEY` name | | decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | -| ICU GET with `x-api-key` | incomplete on merged transport path | remains the one genuine dependency/API gap | +| ICU GET with `x-api-key` | pending stack has header/capture mechanics; arbitrary credential-header redirect semantics are missing | genuine dependency/API gap, tracked by ICU #19 | `Language.JSON` is already part of current Idriç contrib. It parses a string to `Maybe JSON`, with structural `JNull`, `JBoolean`, `JNumber`, `JString`, `JArray`, and `JObject` values plus object-field lookup. The Handshake checkpoint therefore does not carry its own JSON grammar. Only fields needed for the two raw tables are decoded into Handshake records; unrelated response fields remain parsed JSON and are ignored. @@ -101,24 +103,28 @@ Idriç PR #67 is separately restoring the stricter project-level `environment_va ## Genuine remaining transport gap -The current merged Idric-Net HTTP model already has typed HTTP header values, but its normal renderer supplies a fixed header set. Current merged ICU's `send_request` consumes that rendered request and returns a transport result; it does not expose the response body to this caller. +The current merged Idric-Net HTTP model already has typed HTTP header values, but its normal renderer supplies a fixed header set. Current merged ICU does not expose the response-body capture path this caller needs. + +The pending ICU stack is closer: + +- ICU #12 adds file-backed raw response capture; +- ICU #13 is stacked on #12 and adds validated caller-supplied request headers plus `fetch_to_files_with_headers`. -Two pending ICU lines demonstrate the missing halves, but neither is the merged shared API Handshake needs: +So caller-header construction and response capture are not missing inventions. The remaining blocker is redirect credential semantics. ICU #13 strips `Authorization` and `Cookie` when a redirect changes scheme, host, or port, but preserves other custom headers. Current Idric-Net's header classifier makes the same name-based distinction. -- ICU #13 adds checked caller-supplied request headers, including `-H` / `--header`, on its pending branch; -- ICU #12 exposes a file-backed raw response capture seam for the OpenAI client on another pending stack. +Handshake authenticates with `x-api-key`. On that pending generic path, `x-api-key` is therefore an ordinary custom header and could be forwarded to a cross-origin redirect target. ICU #19 tracks the needed shared surface: let the caller declare an arbitrary header credential-sensitive, or provide an equivalent policy, so same-origin redirects can retain it while cross-origin redirects remove it. -Handshake therefore does **not** invent a private transport. `edu_icu_get` currently returns an explicit `Left` explaining that caller-header + response-body support is pending. Live `jobs` and `roles` fail closed there. Once ICU/Idric-Net exposes one current shared request API with both capabilities, that small boundary can be replaced directly. +Handshake therefore does **not** invent a private transport. `edu_icu_get` returns an explicit `Left` naming ICU #19, and live `jobs` and `roles` fail closed there. It does not substitute curl, Python, browser cookies, or a Handshake-specific socket path. ## Checkpoint ladder 1. source parses/checks against current Idriç plus contrib; 2. `url jobs` and `url roles` print the documented endpoints; 3. `public JOB_ID` accepts decimal ids and rejects malformed ids; -4. jobs fixture decodes and matches its TSV receipt; +4. jobs fixture decodes, flattens embedded TSV-breaking whitespace, and matches its TSV receipt; 5. job-role-group fixture decodes and matches its TSV receipt; 6. process environment distinguishes missing and empty API keys; -7. shared ICU/Idric-Net request API accepts caller headers and returns a response body; +7. ICU/Idric-Net supports caller-declared credential-sensitive headers on its caller-header + response-capture path (ICU #19); 8. one live `/jobs` page travels through that API with `x-api-key`; 9. one live `/job_role_groups` page travels through the same boundary; 10. cursor pagination is added without changing the one-page decoder contract; From 5ce16a9dc31048194daf42e73a2c82fa5d65a051 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:33:05 -0400 Subject: [PATCH 13/14] Clarify Handshake's remaining gap in root README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dd07f4c..5180f1c 100644 --- a/README.md +++ b/README.md @@ -28,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 and Handshake have separate manual compiler 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 and Handshake have separate compiler checkpoints under `checkpoints/`. Handshake's offline path is implemented and exercised against current Idriç; only its live EDU request remains fail-closed on ICU credential-sensitive `x-api-key` redirect support (ICU #19). See `PROVENANCE.md` for the source branches copied into this repository. From 59f613e4f1bba084d5c96ab04d419066989d9dd8 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 3 Sep 2026 04:46:21 -0400 Subject: [PATCH 14/14] Normalize Handshake case branch layout for current Idric --- checkpoints/handshake/idric/Handshake.idric | 69 ++++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric index 5af68ee..7a0b446 100644 --- a/checkpoints/handshake/idric/Handshake.idric +++ b/checkpoints/handshake/idric/Handshake.idric @@ -75,7 +75,8 @@ read_fixture path = do case outcome of Left problem => pure (Left ("could not read fixture " ++ path ++ ": " ++ show problem)) - Right body => pure (Right body) + Right body => + pure (Right body) json_field : String → JSON → Maybe JSON @@ -89,37 +90,52 @@ required_integer name object = in if cast integer == value then Right integer else Left ("field " ++ name ++ " must be an integer") - Just _ => Left ("field " ++ name ++ " must be an integer") - Nothing => Left ("missing field " ++ name) + Just _ => + Left ("field " ++ name ++ " must be an integer") + Nothing => + Left ("missing field " ++ name) required_string : String → JSON → Either String String required_string name object = case json_field name object of - Just (JString value) => Right value - Just _ => Left ("field " ++ name ++ " must be a string") - Nothing => Left ("missing field " ++ name) + Just (JString value) => + Right value + Just _ => + Left ("field " ++ name ++ " must be a string") + Nothing => + Left ("missing field " ++ name) required_boolean : String → JSON → Either String Bool required_boolean name object = case json_field name object of - Just (JBoolean value) => Right value - Just _ => Left ("field " ++ name ++ " must be a boolean") - Nothing => Left ("missing field " ++ name) + Just (JBoolean value) => + Right value + Just _ => + Left ("field " ++ name ++ " must be a boolean") + Nothing => + Left ("missing field " ++ name) optional_string : String → JSON → Either String (Maybe String) optional_string name object = case json_field name object of - Nothing => Right Nothing - Just JNull => Right Nothing - Just (JString value) => Right (Just value) - Just _ => Left ("field " ++ name ++ " must be a string or null") + Nothing => + Right Nothing + Just JNull => + Right Nothing + Just (JString value) => + Right (Just value) + Just _ => + Left ("field " ++ name ++ " must be a string or null") response_data : JSON → Either String (List JSON) response_data document = case json_field "data" document of - Just (JArray values) => Right values - Just _ => Left "field data must be an array" - Nothing => Left "missing field data" + Just (JArray values) => + Right values + Just _ => + Left "field data must be an array" + Nothing => + Left "missing field data" decode_job : JSON → Either String Job @@ -162,7 +178,8 @@ decode_jobs_values (value :: rest) = do decode_jobs_response : String → Either String (List Job) decode_jobs_response body = case Language.JSON.parse body of - Nothing => Left "invalid JSON response" + Nothing => + Left "invalid JSON response" Just document => do values ← response_data document decode_jobs_values values @@ -193,7 +210,8 @@ decode_roles_values (value :: rest) = do decode_roles_response : String → Either String (List Job_Role_Group) decode_roles_response body = case Language.JSON.parse body of - Nothing => Left "invalid JSON response" + Nothing => + Left "invalid JSON response" Just document => do values ← response_data document decode_roles_values values @@ -217,8 +235,10 @@ decimal_digits (value :: rest) = valid_job_id : String → Bool valid_job_id text = case unpack text of - [] => False - values => decimal_digits values + [] => + False + values => + decimal_digits values -- Genuine remaining dependency boundary: @@ -315,7 +335,8 @@ read_and_print printer path = do Left problem => do putStrLn ("handshake: " ++ problem) exitFailure - Right body => printer body + Right body => + printer body fetch_and_print : (String → IO ()) → String → IO () fetch_and_print printer url = do @@ -333,7 +354,8 @@ fetch_and_print printer url = do Left problem => do putStrLn ("handshake: " ++ problem) exitFailure - Right body => printer body + Right body => + printer body run : command → IO () @@ -361,7 +383,8 @@ main = do Left problem => do putStrLn problem exitFailure - Right value => run value + Right value => + run value [] => do putStrLn usage exitFailure