#!/usr/bin/env bash # verify-soul-contract.sh — the soul contract gate. # # TERMINOLOGY (canonical): the ENGRAM is the brain — the memory/knowledge-graph # substrate. The binary this gate exercises is the SOUL — the runtime/reasoning # engine compiled from dist/soul.c that serves the /api/ surface. The app # (neuron-ui) bundles the soul binary at resources//neuron. # # WHY THIS EXISTS # For a while the soul binary was hand-dropped, and a stale one shipped: it # 404'd several capability routes the app calls (knowledge-graph node # update/delete, live-run narration, safety-contact, ...). This gate makes # shipping a stale soul IMPOSSIBLE. It has two enforced sections: # A. PRESENCE — every route the app calls must be ANSWERED (not 404, not # the el-runtime "no handler"). This is the packaging gate: # if it fails, do not package. # B. IMMUTABILITY — engram nodes/memories are immutable by design. To # "update" is to create a NEW node + a supersede EDGE back to # the original; the original is KEPT. To "delete" is to # supersede/tombstone, never hard-remove. A soul that # hard-deletes an engram node is DEFECTIVE and fails the gate. # # SAFETY # Never touches the live soul (:7770), live engram (:8742), or ~/.neuron. # Boots on a throwaway port (default 7799) with HOME=$(mktemp -d), a throwaway # engram snapshot, a non-genesis cgi id, no ENGRAM_URL (so it uses its own # in-process store, never the live server), NEURON_API_URL pointed at a dead # port, and no ANTHROPIC_API_KEY (so no probe triggers a real LLM call). # Connectors proxy to a HARDCODED 127.0.0.1:7771 (no env override): those # sub-routes are probed with GET, which the soul maps to a read-only # connectd_get, so this gate never writes to a running connectd bridge. # # USAGE # scripts/verify-soul-contract.sh [port] # exit 0 = all required routes answered AND no destructive engram mutation; # non-zero = a route is missing (presence) or a mutation route hard-deletes. set -uo pipefail SOUL="${1:?usage: verify-soul-contract.sh [port]}" PORT="${2:-7799}" if [ "$PORT" = "7770" ] || [ "$PORT" = "8742" ] || [ "$PORT" = "7771" ]; then echo "REFUSING: port $PORT is a live service port. Use a throwaway port." >&2 exit 2 fi if [ ! -x "$SOUL" ]; then echo "not executable: $SOUL" >&2; exit 2; fi BASE="http://127.0.0.1:$PORT" THROW_HOME="$(mktemp -d "${TMPDIR:-/tmp}/soul-contract-home.XXXXXX")" SOUL_LOG="$(mktemp "${TMPDIR:-/tmp}/soul-contract-log.XXXXXX")" SOUL_PID="" cleanup() { [ -n "$SOUL_PID" ] && kill "$SOUL_PID" 2>/dev/null [ -n "$SOUL_PID" ] && { sleep 0.3; kill -9 "$SOUL_PID" 2>/dev/null; } rm -rf "$THROW_HOME" "$SOUL_LOG" } trap cleanup EXIT INT TERM # ============================================================================= # THE CONTRACT — routes the app (neuron-ui/src/main/kotlin/ai/neuron/ui/*.kt) # calls against the soul ($SOUL). Format: "METHOD PATH". # # EXCLUDED and why: # /api/auth, /api/auth/status, /api/dispatch, /api/tasks # -> served by the APP's own DispatchServer.kt (localhost:8080), not the # soul. Not soul routes. # /api/tags # -> not handled by any soul .el (app-side/other). Pre-verified excluded. # /api/neuron/, /api/neuron/node/, /api/connectors/ (bare prefixes) # -> base-path string constants used to build the concrete routes below. # # KNOWN-PENDING (probed + reported, NON-blocking): # POST /api/engram/import -> the app's "Restore memory" path. No soul handler # yet, and the app hides Restore from shipped builds (B3, "lands in an # update"). Reported so we see it; does not block packaging. # # Connectors sub-routes are listed as GET (see SAFETY note): handle_connectors is # monolithic, so a GET reaching it proves the whole connectors surface without # writing to the live bridge. Binary-strings cross-check confirms each POST # sub-path literal is compiled in. # ============================================================================= REQUIRED=( "GET /api/graph/nodes" "GET /api/graph/edges" "POST /api/chat" "GET /api/config" "POST /api/see" "GET /api/connectors" "GET /api/connectors/add" "GET /api/connectors/toggle" "GET /api/connectors/auto-approve" "GET /api/connectors/remove" "GET /api/connectors/secret" "GET /api/connectors/oauth/start" "GET /api/connectors/call" "POST /api/neuron/memory" "POST /api/neuron/memory/update" "POST /api/neuron/memory/delete" "POST /api/neuron/node/create" "POST /api/neuron/node/update" "POST /api/neuron/node/delete" "POST /api/neuron/knowledge/capture" "POST /api/neuron/knowledge/evolve" "POST /api/neuron/knowledge/promote" "POST /api/neuron/processes/define" "GET /api/run-progress/__contract_probe__" "GET /api/safety-contact" "POST /api/safety-contact" "GET /api/sessions/__contract_probe__" ) KNOWN_PENDING=( "POST /api/engram/import" ) # --- boot the soul ----------------------------------------------------------- echo "== booting soul: $SOUL on port $PORT (throwaway HOME=$THROW_HOME) ==" env -i \ PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ HOME="$THROW_HOME" \ NEURON_PORT="$PORT" \ SOUL_CGI_ID="ntn-contract-$$" \ SOUL_ENGRAM_PATH="$THROW_HOME/throwaway-snapshot.json" \ NEURON_API_URL="http://127.0.0.1:9" \ SOUL_TICK_MS="3600000" SOUL_HEARTBEAT_MS="3600000" SOUL_REFRESH_MS="3600000" \ "$SOUL" >"$SOUL_LOG" 2>&1 & SOUL_PID=$! UP=0 for _ in $(seq 1 60); do if ! kill -0 "$SOUL_PID" 2>/dev/null; then echo "!! soul exited during boot. log tail:" >&2; tail -20 "$SOUL_LOG" >&2; exit 3 fi RSS=$(ps -o rss= -p "$SOUL_PID" 2>/dev/null | tr -d ' ') if [ -n "$RSS" ] && [ "$RSS" -gt $((3*1024*1024)) ]; then echo "!! soul RSS >3GB — kill -9" >&2; kill -9 "$SOUL_PID" 2>/dev/null; exit 3 fi [ "$(curl -s -o /dev/null -w '%{http_code}' -m 2 "$BASE/health" 2>/dev/null)" = "200" ] && { UP=1; break; } sleep 0.5 done [ "$UP" = 1 ] || { echo "!! soul never healthy on $BASE/health" >&2; tail -20 "$SOUL_LOG" >&2; exit 3; } echo "== soul healthy =="; echo # --- probing helpers --------------------------------------------------------- # request METHOD PATH [BODY] -> prints response body (single line) request() { curl -s -m 12 -X "$1" -H 'Content-Type: application/json' --data "${3:-{}}" "$BASE$2" 2>/dev/null | tr -d '\n' } # is_missing BODY -> 0 if the body is a "route not present" signal is_missing() { printf '%s' "$1" | grep -qE '"error":"not found"|"code":"not_found"|no http handler registered|"code":"method_not_allowed"' } extract_id() { printf '%s' "$1" | grep -oE '"id":"[^"]+"' | head -1 | sed 's/.*"id":"//;s/"//'; } node_present() { # id -> 0 if id appears in /api/graph/nodes request GET /api/graph/nodes | grep -qF "\"$1\"" } # --- SECTION A: presence ----------------------------------------------------- run_presence() { local -n arr=$1; local fail=0 printf ' %-8s %-42s %s\n' "METHOD" "ROUTE" "RESULT" for e in "${arr[@]}"; do local m p body; m=$(awk '{print $1}' <<<"$e"); p=$(awk '{print $2}' <<<"$e") body=$(request "$m" "$p") if is_missing "$body"; then printf ' %-8s %-42s MISSING %s\n' "$m" "$p" "$(cut -c1-46 <<<"$body")"; fail=$((fail+1)) else printf ' %-8s %-42s ANSWERED %s\n' "$m" "$p" "$(cut -c1-46 <<<"$body")" fi done return $fail } echo "== SECTION A: PRESENCE (required, blocking) ==" run_presence REQUIRED; A_FAIL=$? echo echo "== KNOWN-PENDING (non-blocking) ==" run_presence KNOWN_PENDING; P_FAIL=$? echo # --- SECTION B: immutability (engram write routes must supersede, not destroy) -- # For each mutation route: create a node, mutate it, then check the ORIGINAL id # still exists in the graph. KEPT = supersede/tombstone (correct). DESTROYED = # hard delete (DEFECTIVE -> fail). N/A = mutate route absent (a presence failure). # For "delete" mutations we additionally require a real tombstone marker # (label "tombstone:") so a no-op delete cannot false-pass as KEPT. marker_present() { # id -> 0 if a "tombstone:" marker exists (include_deleted view) request GET "/api/graph/nodes?include_deleted=1" | grep -qF "tombstone:$1" } immut_check() { # label KIND(update|delete) CREATE_PATH MUTATE_PATH local label="$1" kind="$2" create="$3" mutate="$4" local cbody id mb mbody cbody=$(request POST "$create" "{\"content\":\"__immut_${label}__\",\"node_type\":\"Memory\",\"label\":\"contract:immut\"}") id=$(extract_id "$cbody") if [ -z "$id" ]; then printf ' %-14s SKELETON-FAIL create returned no id: %s\n' "$label" "$(cut -c1-40 <<<"$cbody")"; return 2; fi mb="{\"id\":\"$id\"}"; [ "$kind" = update ] && mb="{\"id\":\"$id\",\"content\":\"__immut_${label}_v2__\"}" mbody=$(request POST "$mutate" "$mb") if is_missing "$mbody"; then printf ' %-14s N/A mutate route absent (see Section A)\n' "$label"; return 0; fi if ! node_present "$id"; then printf ' %-14s DESTROYED original %s hard-removed <== DEFECTIVE\n' "$label" "$id"; return 1 fi if [ "$kind" = delete ] && ! marker_present "$id"; then printf ' %-14s NO-OP original %s kept but no tombstone marker <== DEFECTIVE\n' "$label" "$id"; return 1 fi local how="supersede edge"; [ "$kind" = delete ] && how="tombstoned + hidden from default list" printf ' %-14s KEPT original %s survived (%s)\n' "$label" "$id" "$how"; return 0 } echo "== SECTION B: IMMUTABILITY (engram nodes must be superseded, never destroyed) ==" B_FAIL=0 immut_check "memory-update" update /api/neuron/memory /api/neuron/memory/update || B_FAIL=$((B_FAIL+$?)) immut_check "memory-delete" delete /api/neuron/memory /api/neuron/memory/delete || B_FAIL=$((B_FAIL+$?)) immut_check "node-update" update /api/neuron/node/create /api/neuron/node/update || B_FAIL=$((B_FAIL+$?)) immut_check "node-delete" delete /api/neuron/node/create /api/neuron/node/delete || B_FAIL=$((B_FAIL+$?)) immut_check "memory-forget" delete /api/neuron/memory /api/neuron/memory/forget || B_FAIL=$((B_FAIL+$?)) echo echo "============================================================" RC=0 if [ "$A_FAIL" -gt 0 ]; then echo "PRESENCE: FAIL — $A_FAIL required route(s) unanswered. Do NOT package."; RC=1 else echo "PRESENCE: PASS — all ${#REQUIRED[@]} required routes answered."; fi if [ "$B_FAIL" -gt 0 ]; then echo "IMMUTABILITY: FAIL — $B_FAIL engram write route(s) hard-delete. DEFECTIVE soul."; RC=1 else echo "IMMUTABILITY: PASS — no engram write route hard-deletes."; fi [ "$P_FAIL" -gt 0 ] && echo "note: $P_FAIL known-pending route(s) unanswered (expected; non-blocking)." echo "============================================================" [ "$RC" = 0 ] && echo "GATE: PASS" || echo "GATE: FAIL" exit $RC