#!/usr/bin/env bash # verify-state-keys.sh — the state-key gate. Retires a defect class at build time. # # ── WHY THIS EXISTS. DO NOT DELETE IT AS NOISE. ────────────────────────────── # # The engine keeps runtime values in a key-value store: state_set("k", v) writes, # state_get("k") reads. A read of a key that NOTHING writes returns an empty # string. Silently. No error, no warning, no log line. The El compiler cannot see # it, no test sees it, and the product keeps running — just with a hole in it. # # That is how issue #129 happened. ff421d3 (2026-08-05) correctly moved # conversation history to a per-session key behind conv_hist_key(session_id). One # consumer did not move with it: the agentic path's L1 safety screen kept reading # the old anonymous "conv_history" bucket. The desktop app always mints a session # id, so history was always written under session_hist_ and that read always # returned "". The half of the crisis score that receives history is the # ESCALATION half — the one that exists for distress building across several # turns, where no single message trips the bell on its own. It scored 0 on every # real conversation for two days, and nothing failed. # # The line that broke carried a comment describing this exact bug being fixed # once already, under issue #9. A comment is not a gate. This is the gate. # # ── WHAT IT CHECKS ────────────────────────────────────────────────────────── # # DEAD-READ a state_get whose key resolves to something no state_set in the # tree produces. The direct form of the class. # # HAND-ROLLED a state_get/state_set that spells out a literal belonging to a # key namespace a helper function owns (e.g. "conv_history", owned # by conv_hist_key()). This is #129's actual shape: the producer # moved behind the helper and one consumer kept the old spelling # by hand. DEAD-READ alone does NOT catch #129, because the dead # handle_chat() still writes that key through the helper — so this # second check is the one that earns the gate its keep. # # ── WHY IT DOES NOT CRY WOLF ──────────────────────────────────────────────── # # Keys are usually COMPUTED, not literal, so a naive grep would flood and get # switched off within a day. scripts/state-key-audit.py resolves computed keys: # string concatenation (matched on the static prefix), helper functions (resolved # to their possible return values), keys built into a local variable, and keys # arriving as a function parameter (resolved through the call sites). Where a key # genuinely cannot be resolved it is printed under UNRESOLVED and does NOT fail # the build — visible, never silently ignored. Keep that list short. # # On this tree it resolves 278 of 278 sites: UNRESOLVED is 0 and FINDINGS is 0. # # Two declaration files, both of which should only ever shrink: # scripts/state-key-external.txt keys a host outside the El tree writes # scripts/state-key-baseline.txt findings that predate the gate (real debt) # # ── PROVEN TO DISCRIMINATE (2026-08-07) ───────────────────────────────────── # # 1. Synthetic: a scratch copy of this tree with agentic_safety_screen reverted # to the pre-fix state_get("conv_history") — ONE line, nothing else — FAILS # with `chat.el:2536 ... conv_hist_key() owns this key namespace`. The tree # as shipped PASSES. One variable, opposite verdicts. # 2. Independent: run read-only against origin/feat/soul-openai-tools-v2, which # carries the same defect on its own, the gate reported chat.el:2937 — the # exact line 43d0449's commit message had named by hand. Against that # branch's fix (origin/fix/129-on-openai-tools) it passes. # 3. Producer-moved controls: renaming the sole writer of an EXACT key # (soul_model) orphans 3 readers across 3 files; renaming the sole writer of # a PREFIX namespace (agent_workspace_root_*) orphans 3 readers — including # when the producer moves to a NARROWER namespace, which an earlier, # sloppier prefix rule let through. # # It also found, on its first run, a defect nobody was looking for: soul.el's # `state_set("soul_identity", ...)` was deleted on 2026-05-13 in b163fa6 (a # commit about awareness/ISE writes) and five readers in chat.el were left # behind — the system prompt, the vision handler, the agentic prompt and the # council handler have been prefixing "" ever since. See state-key-baseline.txt. # # ── SAFETY ────────────────────────────────────────────────────────────────── # Pure static read of .el sources. Starts nothing, opens no port, touches no # daemon, and never reads or writes ~/.neuron. # # ── USAGE ─────────────────────────────────────────────────────────────────── # scripts/verify-state-keys.sh gate the repo (honours baseline) # scripts/verify-state-keys.sh --strict ignore the baseline: show the debt # scripts/verify-state-keys.sh --verbose also dump every write pattern # scripts/verify-state-keys.sh --root DIR audit a different tree # exit 0 = clean; 1 = finding(s); 2 = the gate itself could not run. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" STRICT=0 PASS_THROUGH=() while [ $# -gt 0 ]; do case "$1" in --strict) STRICT=1; shift ;; --root) ROOT="${2:?--root needs a directory}"; shift 2 ;; -h|--help) awk 'NR>1 && /^#/ {print; next} NR>1 {exit}' "${BASH_SOURCE[0]}"; exit 0 ;; *) PASS_THROUGH+=("$1"); shift ;; esac done command -v python3 >/dev/null 2>&1 || { echo "[state-keys] CANNOT RUN: python3 not found" >&2; exit 2; } [ -d "$ROOT" ] || { echo "[state-keys] CANNOT RUN: no such tree: $ROOT" >&2; exit 2; } AUDIT="$SCRIPT_DIR/state-key-audit.py" [ -f "$AUDIT" ] || { echo "[state-keys] CANNOT RUN: missing $AUDIT" >&2; exit 2; } ARGS=("$ROOT" "--external" "$SCRIPT_DIR/state-key-external.txt") [ "$STRICT" -eq 0 ] && ARGS+=("--baseline" "$SCRIPT_DIR/state-key-baseline.txt") [ ${#PASS_THROUGH[@]} -gt 0 ] && ARGS+=("${PASS_THROUGH[@]}") python3 "$AUDIT" "${ARGS[@]}" RC=$? if [ "$RC" -gt 1 ]; then echo "[state-keys] CANNOT RUN: the audit itself failed (exit $RC)" >&2 exit 2 fi exit "$RC"