From b385a874e9fa6365a23f6c4c275ef081064eef9d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 2 Sep 2026 22:29:58 -0400 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 From 4a87c8e933cc76b17359159b9fe9414ed88f3c94 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:43:55 -0400 Subject: [PATCH 15/22] Use ICU credential-safe GET for Handshake --- checkpoints/handshake/idric/Handshake.idric | 90 +++++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric index 7a0b446..e9b3c5c 100644 --- a/checkpoints/handshake/idric/Handshake.idric +++ b/checkpoints/handshake/idric/Handshake.idric @@ -1,9 +1,11 @@ module Handshake import Data.String +import Http import Language.JSON import System import System.File +import Transport edu_base : String @@ -219,11 +221,20 @@ decode_roles_response body = -- 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. +-- but this client asks only for fixed, valid environment-variable names. environment_value : String → IO (Maybe String) environment_value name = getEnv name +environment_value_or : String → String → IO String +environment_value_or name fallback = do + value ← environment_value name + case value of + Nothing => pure fallback + Just "" => pure fallback + Just text => pure text + + decimal_digit : Char → Bool decimal_digit value = value >= '0' && value <= '9' @@ -241,22 +252,67 @@ valid_job_id text = decimal_digits values --- Genuine remaining dependency boundary: --- 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). +cleanup_file : String → IO () +cleanup_file path = do + outcome ← removeFile path + case outcome of + Left _ => pure () + Right () => pure () + +cleanup_files : String → String → IO () +cleanup_files body_path metadata_path = do + cleanup_file body_path + cleanup_file metadata_path + + edu_icu_get : String → String → IO (Either String String) edu_icu_get api_key url = - pure - (Left - "live EDU GET pending ICU credential-safe x-api-key redirect policy (icu#19)") - + case parse_url url of + Left problem => + pure (Left ("invalid Handshake EDU URL: " ++ problem)) + Right target_url => + case make_credential_header "x-api-key" api_key of + Left _ => + pure (Left "HANDSHAKE_EDU_API_KEY contains an invalid HTTP header character") + Right key_header => do + pid ← getPID + temp_root ← environment_value_or "TMPDIR" "/tmp" + let prefix = temp_root ++ "/idric-handshake-" ++ show pid + let body_path = prefix ++ ".json" + let metadata_path = prefix ++ ".meta" + fetch_result ← + fetch_to_files_with_headers + (get target_url) + [key_header] + body_path + metadata_path + case fetch_result of + Left problem => do + cleanup_files body_path metadata_path + pure (Left problem) + Right transport_code => do + body_result ← readFile body_path + cleanup_files body_path metadata_path + if transport_code /= 0 + then pure + (Left + ("ICU request failed: transport_code=" ++ show transport_code)) + else case body_result of + Left _ => pure (Left "could not read Handshake EDU response body") + Right body => pure (Right body) + + +endpoint_url_from : String → endpoint → String +endpoint_url_from base jobs_endpoint = base ++ "/jobs?page_size=100" +endpoint_url_from base roles_endpoint = base ++ "/job_role_groups?page_size=100" 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" +endpoint_url = endpoint_url_from edu_base + +live_endpoint_url : endpoint → IO String +live_endpoint_url which = do + base ← environment_value_or "HANDSHAKE_EDU_BASE_URL" edu_base + pure (endpoint_url_from base which) parse_command : List String → Either String command @@ -370,8 +426,12 @@ run (public_job job_id) = 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) +run fetch_jobs = do + url ← live_endpoint_url jobs_endpoint + fetch_and_print print_jobs_response url +run fetch_roles = do + url ← live_endpoint_url roles_endpoint + fetch_and_print print_roles_response url main : IO () From eaf02dca2a34dc365f5968bafa847bcb150d9ab7 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:44:17 -0400 Subject: [PATCH 16/22] Keep Handshake source mirror in sync --- Handshake.idric | 451 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 450 insertions(+), 1 deletion(-) mode change 120000 => 100644 Handshake.idric diff --git a/Handshake.idric b/Handshake.idric deleted file mode 120000 index f271826..0000000 --- a/Handshake.idric +++ /dev/null @@ -1 +0,0 @@ -checkpoints/handshake/idric/Handshake.idric \ No newline at end of file diff --git a/Handshake.idric b/Handshake.idric new file mode 100644 index 0000000..e9b3c5c --- /dev/null +++ b/Handshake.idric @@ -0,0 +1,450 @@ +module Handshake + +import Data.String +import Http +import Language.JSON +import System +import System.File +import Transport + + +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 + + +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 = pack (clean_field_chars (unpack text)) + + +read_fixture : String → IO (Either String String) +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) + + +json_field : String → JSON → Maybe JSON +json_field name object = Language.JSON.lookup name object + +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 → 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 → 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 → 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") + +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" + + +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 + 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 → 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 = + 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 + 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 → 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 = + 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. +-- Idriç's stricter environment_value wrapper is being restored separately, +-- but this client asks only for fixed, valid environment-variable names. +environment_value : String → IO (Maybe String) +environment_value name = getEnv name + + +environment_value_or : String → String → IO String +environment_value_or name fallback = do + value ← environment_value name + case value of + Nothing => pure fallback + Just "" => pure fallback + Just text => pure text + + +decimal_digit : Char → Bool +decimal_digit value = value >= '0' && value <= '9' + +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 = + case unpack text of + [] => + False + values => + decimal_digits values + + +cleanup_file : String → IO () +cleanup_file path = do + outcome ← removeFile path + case outcome of + Left _ => pure () + Right () => pure () + +cleanup_files : String → String → IO () +cleanup_files body_path metadata_path = do + cleanup_file body_path + cleanup_file metadata_path + + +edu_icu_get : String → String → IO (Either String String) +edu_icu_get api_key url = + case parse_url url of + Left problem => + pure (Left ("invalid Handshake EDU URL: " ++ problem)) + Right target_url => + case make_credential_header "x-api-key" api_key of + Left _ => + pure (Left "HANDSHAKE_EDU_API_KEY contains an invalid HTTP header character") + Right key_header => do + pid ← getPID + temp_root ← environment_value_or "TMPDIR" "/tmp" + let prefix = temp_root ++ "/idric-handshake-" ++ show pid + let body_path = prefix ++ ".json" + let metadata_path = prefix ++ ".meta" + fetch_result ← + fetch_to_files_with_headers + (get target_url) + [key_header] + body_path + metadata_path + case fetch_result of + Left problem => do + cleanup_files body_path metadata_path + pure (Left problem) + Right transport_code => do + body_result ← readFile body_path + cleanup_files body_path metadata_path + if transport_code /= 0 + then pure + (Left + ("ICU request failed: transport_code=" ++ show transport_code)) + else case body_result of + Left _ => pure (Left "could not read Handshake EDU response body") + Right body => pure (Right body) + + +endpoint_url_from : String → endpoint → String +endpoint_url_from base jobs_endpoint = base ++ "/jobs?page_size=100" +endpoint_url_from base roles_endpoint = base ++ "/job_role_groups?page_size=100" + +endpoint_url : endpoint → String +endpoint_url = endpoint_url_from edu_base + +live_endpoint_url : endpoint → IO String +live_endpoint_url which = do + base ← environment_value_or "HANDSHAKE_EDU_BASE_URL" edu_base + pure (endpoint_url_from base which) + + +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 "" => do + putStrLn "handshake: HANDSHAKE_EDU_API_KEY is empty" + 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 = do + url ← live_endpoint_url jobs_endpoint + fetch_and_print print_jobs_response url +run fetch_roles = do + url ← live_endpoint_url roles_endpoint + fetch_and_print print_roles_response url + + +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 2b7914be2ee3786e13a68d00084ee5fb73a8f9bd Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:44:53 -0400 Subject: [PATCH 17/22] Exercise Handshake through ICU redirect policy --- checkpoints/handshake/check | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/checkpoints/handshake/check b/checkpoints/handshake/check index ed950f5..c6085a7 100755 --- a/checkpoints/handshake/check +++ b/checkpoints/handshake/check @@ -1,8 +1,8 @@ #!/usr/bin/env ysh # 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. +# Offline surfaces are always executable. When a deterministic EDU base URL is +# supplied, the live command is exercised through ICU with a synthetic key. set -u @@ -174,9 +174,21 @@ expect_failure_output \ '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 credential-safe x-api-key redirect policy (icu#19)' \ - env HANDSHAKE_EDU_API_KEY=synthetic "$PROGRAM" jobs +if test -n "${HANDSHAKE_TEST_EDU_BASE_URL:-}"; then + if env \ + HANDSHAKE_EDU_API_KEY=synthetic \ + HANDSHAKE_EDU_BASE_URL="$HANDSHAKE_TEST_EDU_BASE_URL" \ + "$PROGRAM" jobs \ + >"$TMP/live-jobs.tsv" 2>"$TMP/live-jobs.err" && + cmp -s "$HERE/fixture/jobs.expected.tsv" "$TMP/live-jobs.tsv"; then + pass 'transport/credential-redirects' + else + fail 'transport/credential-redirects' + diff -u "$HERE/fixture/jobs.expected.tsv" "$TMP/live-jobs.tsv" || true + show_diagnostics "$TMP/live-jobs.err" + fi +else + skip 'transport/credential-redirects (HANDSHAKE_TEST_EDU_BASE_URL not set)' +fi exit "$status" From 1a3ca20ac961df7641f1f56e88e896a0449aca8f Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:45:14 -0400 Subject: [PATCH 18/22] Run Handshake against ICU credential redirect stack --- .github/workflows/handshake-idric.yml | 91 ++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml index 26c5c9f..788f904 100644 --- a/.github/workflows/handshake-idric.yml +++ b/.github/workflows/handshake-idric.yml @@ -3,6 +3,7 @@ name: Handshake Idric checkpoint on: pull_request: paths: + - 'Handshake.idric' - 'checkpoints/handshake/**' - '.github/workflows/handshake-idric.yml' workflow_dispatch: @@ -24,10 +25,17 @@ jobs: ref: Idriç path: .tools/Idric + - name: Checkout ICU credential-header stack + uses: actions/checkout@v4 + with: + repository: dilapidated-shed/icu + ref: credential-sensitive-headers + path: .tools/icu + - name: Install host build tools run: | sudo apt-get update - sudo apt-get install -y build-essential curl + sudo apt-get install -y build-essential curl libssl-dev - name: Bootstrap Idric run: .tools/Idric/_/edric bootstrap @@ -35,9 +43,88 @@ jobs: - name: Expose pinned Chez run: echo "$GITHUB_WORKSPACE/.tools/Idric/_/.tools/bin" >> "$GITHUB_PATH" + - name: Build and check ICU transport + env: + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + run: | + make -C "$GITHUB_WORKSPACE/.tools/icu" check-native + make -C "$GITHUB_WORKSPACE/.tools/icu" \ + IDRIC="$GITHUB_WORKSPACE/.tools/Idric/_/build/exec/idris2" + + - name: Start deterministic credential redirect servers + env: + HANDSHAKE_JOBS_FIXTURE: ${{ github.workspace }}/checkpoints/handshake/fixture/jobs.json + run: | + cat > "$RUNNER_TEMP/handshake-redirect.py" <<'PY' + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from pathlib import Path + import os + import threading + + fixture = Path(os.environ["HANDSHAKE_JOBS_FIXTURE"]).read_bytes() + + class FirstOrigin(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, format, *args): + pass + + def redirect(self, location): + self.send_response(302) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + if self.headers.get("x-api-key") != "synthetic": + self.send_error(400, "x-api-key missing on same-origin hop") + return + if self.path == "/v1/jobs?page_size=100": + self.redirect("/v1/jobs/continued") + return + if self.path == "/v1/jobs/continued": + self.redirect("http://127.0.0.1:18084/final") + return + self.send_error(404) + + class SecondOrigin(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, format, *args): + pass + + def do_GET(self): + if self.path != "/final": + self.send_error(404) + return + if self.headers.get("x-api-key") is not None: + self.send_error(400, "x-api-key leaked across origin") + return + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(fixture))) + self.end_headers() + self.wfile.write(fixture) + + first = ThreadingHTTPServer(("127.0.0.1", 18083), FirstOrigin) + second = ThreadingHTTPServer(("127.0.0.1", 18084), SecondOrigin) + threading.Thread(target=first.serve_forever, daemon=True).start() + second.serve_forever() + PY + python3 "$RUNNER_TEMP/handshake-redirect.py" \ + > "$RUNNER_TEMP/handshake-redirect.log" 2>&1 & + echo $! > "$RUNNER_TEMP/handshake-redirect.pid" + sleep 1 + - name: Run Handshake checkpoint 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 + IDRIS2_PATH: ${{ github.workspace }}/.tools/Idric/_/libs/contrib/build/ttc:${{ github.workspace }}/.tools/icu/build/ttc + LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/icu + HANDSHAKE_TEST_EDU_BASE_URL: http://127.0.0.1:18083/v1 run: bash checkpoints/handshake/check + + - name: Show redirect server log on failure + if: failure() + run: cat "$RUNNER_TEMP/handshake-redirect.log" 2>/dev/null || true From e1088c8c567f17492b391261c61bb225a3f19be0 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:45:57 -0400 Subject: [PATCH 19/22] Preserve Handshake source symlink --- Handshake.idric | 451 +----------------------------------------------- 1 file changed, 1 insertion(+), 450 deletions(-) mode change 100644 => 120000 Handshake.idric diff --git a/Handshake.idric b/Handshake.idric deleted file mode 100644 index e9b3c5c..0000000 --- a/Handshake.idric +++ /dev/null @@ -1,450 +0,0 @@ -module Handshake - -import Data.String -import Http -import Language.JSON -import System -import System.File -import Transport - - -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 - - -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 = pack (clean_field_chars (unpack text)) - - -read_fixture : String → IO (Either String String) -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) - - -json_field : String → JSON → Maybe JSON -json_field name object = Language.JSON.lookup name object - -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 → 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 → 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 → 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") - -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" - - -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 - 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 → 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 = - 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 - 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 → 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 = - 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. --- Idriç's stricter environment_value wrapper is being restored separately, --- but this client asks only for fixed, valid environment-variable names. -environment_value : String → IO (Maybe String) -environment_value name = getEnv name - - -environment_value_or : String → String → IO String -environment_value_or name fallback = do - value ← environment_value name - case value of - Nothing => pure fallback - Just "" => pure fallback - Just text => pure text - - -decimal_digit : Char → Bool -decimal_digit value = value >= '0' && value <= '9' - -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 = - case unpack text of - [] => - False - values => - decimal_digits values - - -cleanup_file : String → IO () -cleanup_file path = do - outcome ← removeFile path - case outcome of - Left _ => pure () - Right () => pure () - -cleanup_files : String → String → IO () -cleanup_files body_path metadata_path = do - cleanup_file body_path - cleanup_file metadata_path - - -edu_icu_get : String → String → IO (Either String String) -edu_icu_get api_key url = - case parse_url url of - Left problem => - pure (Left ("invalid Handshake EDU URL: " ++ problem)) - Right target_url => - case make_credential_header "x-api-key" api_key of - Left _ => - pure (Left "HANDSHAKE_EDU_API_KEY contains an invalid HTTP header character") - Right key_header => do - pid ← getPID - temp_root ← environment_value_or "TMPDIR" "/tmp" - let prefix = temp_root ++ "/idric-handshake-" ++ show pid - let body_path = prefix ++ ".json" - let metadata_path = prefix ++ ".meta" - fetch_result ← - fetch_to_files_with_headers - (get target_url) - [key_header] - body_path - metadata_path - case fetch_result of - Left problem => do - cleanup_files body_path metadata_path - pure (Left problem) - Right transport_code => do - body_result ← readFile body_path - cleanup_files body_path metadata_path - if transport_code /= 0 - then pure - (Left - ("ICU request failed: transport_code=" ++ show transport_code)) - else case body_result of - Left _ => pure (Left "could not read Handshake EDU response body") - Right body => pure (Right body) - - -endpoint_url_from : String → endpoint → String -endpoint_url_from base jobs_endpoint = base ++ "/jobs?page_size=100" -endpoint_url_from base roles_endpoint = base ++ "/job_role_groups?page_size=100" - -endpoint_url : endpoint → String -endpoint_url = endpoint_url_from edu_base - -live_endpoint_url : endpoint → IO String -live_endpoint_url which = do - base ← environment_value_or "HANDSHAKE_EDU_BASE_URL" edu_base - pure (endpoint_url_from base which) - - -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 "" => do - putStrLn "handshake: HANDSHAKE_EDU_API_KEY is empty" - 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 = do - url ← live_endpoint_url jobs_endpoint - fetch_and_print print_jobs_response url -run fetch_roles = do - url ← live_endpoint_url roles_endpoint - fetch_and_print print_roles_response url - - -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 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 From 24778b26f0ff3b033fef579f3405ac3e83647f36 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:46:24 -0400 Subject: [PATCH 20/22] Update Handshake transport status --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5180f1c..2745da6 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ Some Idriç clients intentionally contain named holes for compiler/library bound ## Networking -Where these clients need networking, ICU/Idric-Net remains the intended transport boundary. ICU is deliberately **not** a Git submodule here and is not pinned by this repository. +Where these clients need networking, ICU/Idric-Net remains the intended transport boundary. ICU is deliberately **not** a Git submodule here. While ICU's caller-header/capture stack is still pending, the Handshake compiler checkpoint checks out that current stack explicitly so its transport dependency is executable rather than replaced by curl or Python. ## Tests -`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). +`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit and Handshake have separate compiler checkpoints under `checkpoints/`. Handshake now uses ICU's caller-declared credential-header surface from ICU #20: `x-api-key` is retained across same-origin redirects and stripped before a cross-origin request. The deterministic Handshake checkpoint exercises that redirect chain with a synthetic key and fixture response. See `PROVENANCE.md` for the source branches copied into this repository. From 4708a6f8365e375c321bcf4ff75c5bdd2d24c787 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:46:43 -0400 Subject: [PATCH 21/22] Document credential-safe Handshake transport --- checkpoints/handshake/README.md | 44 +++++++++++++++------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md index defd06a..cd643e5 100644 --- a/checkpoints/handshake/README.md +++ b/checkpoints/handshake/README.md @@ -75,15 +75,17 @@ The jobs fixture includes escaped tab/newline characters in a title while its ex ### `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. +Read `HANDSHAKE_EDU_API_KEY`, build `x-api-key` with ICU's caller-declared credential-header surface, perform one ICU GET with file-backed response capture, decode the response, and emit the same TSV shape as the fixtures. -The live transport call currently fails closed at the credential-policy boundary described below. +The credential tag does not change the header's wire representation. Its only policy effect is on redirects: same-origin redirects retain `x-api-key`; a change of scheme, host, or port strips it before the redirected request is sent. -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. +This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should follow after a single live page is accepted. + +`HANDSHAKE_EDU_BASE_URL` exists so deterministic acceptance can direct the live command at a local fixture server. The `url jobs` and `url roles` commands continue to report the documented production URLs. ## Surface audit -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: +The original draft made seven named holes. They are now either implemented through current Idriç surfaces/small client-local logic or covered by the pending ICU stack: | Original hole | Present surface | Current treatment | | --- | --- | --- | @@ -91,44 +93,38 @@ The original draft made seven named holes. Six do not require a new shared depen | fixture input | `System.File.readFile : ... → Either FileError String` | implemented with explicit file-error text | | 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 | +| environment access | `System.getEnv : ... → Maybe String` | direct wrapper for fixed valid names | | decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | -| 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 | +| ICU GET with `x-api-key` | ICU #13 header/capture mechanics plus ICU #20 caller-declared credential headers | implemented; pending that ICU stack landing | `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`. - -## 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 does not expose the response-body capture path this caller needs. - -The pending ICU stack is closer: +Idriç PR #67 is separately restoring the stricter project-level `environment_value : String → IO (Maybe String)` wrapper after the source-layout rewrite. Handshake does not need to block on it: this client queries fixed valid names and current `System.getEnv` already returns `Maybe String`. -- 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`. +## Credential redirect boundary -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 supplies validated caller headers and `fetch_to_files_with_headers`. ICU #20, stacked directly on #13, adds `make_credential_header` and carries only the declared credential header names into native redirect handling. -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. +For Handshake the request is therefore constructed as a normal GET with a credential-tagged `x-api-key`. On same-origin redirects the full request header survives. On cross-origin redirects ICU removes `Authorization`, `Cookie`, and all caller-declared credential headers while preserving unrelated custom headers and rewriting `Host`. -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. +Handshake does not invent a private transport. 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; +1. source parses/checks against current Idriç, contrib, and the current ICU header/capture stack; 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, 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. 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; -11. a separate analysis command joins jobs and role groups by `job_id` and reports classification evidence without altering raw observations. +7. ICU proves caller-declared credential headers survive same-origin redirects and are stripped cross-origin; +8. the Handshake executable traverses a deterministic same-origin then cross-origin redirect chain with synthetic `x-api-key` and returns the jobs fixture only if both header conditions hold; +9. one actual `/jobs` page can be accepted with an authorized EDU key; +10. one actual `/job_role_groups` page can be accepted through the same boundary; +11. cursor pagination is added without changing the one-page decoder contract; +12. 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 83b29ef3bdc85313b0f00124bb54ed14f0ac71c8 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 4 Sep 2026 18:55:50 -0400 Subject: [PATCH 22/22] Record Handshake transport acceptance heads --- .github/workflows/handshake-idric.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml index 788f904..bb2c6b3 100644 --- a/.github/workflows/handshake-idric.yml +++ b/.github/workflows/handshake-idric.yml @@ -32,6 +32,11 @@ jobs: ref: credential-sensitive-headers path: .tools/icu + - name: Record tested source heads + run: | + printf 'Idric\t%s\n' "$(git -C "$GITHUB_WORKSPACE/.tools/Idric" rev-parse HEAD)" + printf 'ICU\t%s\n' "$(git -C "$GITHUB_WORKSPACE/.tools/icu" rev-parse HEAD)" + - name: Install host build tools run: | sudo apt-get update