#!/usr/bin/env bash # test-wake-reconcile.sh — RED-FIRST invariant harness for W5 (EPIC #892): # the source-parity reconciler (reconcile.sh, A7). # # Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if # that invariant regresses: # R1 complete inventory -> PASS (exit 0) (§4/G3-i) # R2 an OMITTED source (declared but unwatched) -> FLAG; the §4 vector # cannot pass VACUOUSLY, and it is NOT silently green (§4/G3-i) # R3 a required_sources omission -> FLAG (explicit lane-dependency form) (§4/G3-i) # R4 a dangling watch reference -> FLAG (§4/G3-i) # R5 an UNACCOUNTED source-state -> FLAGGED (found + non-zero exit) (§4/G3-ii) # R6 pre-existing state at startup -> ENUMERATED into the durable store; # a follow-up reconcile then reports 0 unaccounted (W4-division) # R7 a source already reflected in the inbox -> ACCOUNTED (no double- # enumeration of detector-delivered state) (§4/G3-ii) # R8 a source adapter error -> FAIL LOUD (G2a parity; never 'no state') (§4/G2a) # R9 co-feed NO-ALIAS: detector + reconciler share the ONE store-side # observed_seq allocator, so co-feeding one store yields DISTINCT, # contiguous seqs (no aliasing). Formerly the fail-closed dual- # allocator refusal; #908 dissolves the hazard at the root. (§4/G3, #908) # R10 a CONSUMED detector state is ACCOUNTED via the store's last-consumed # record — NOT re-enumerated (no dup wake, no spurious rc=1 CRITICAL). (#932) # R11 pilot repro: consumed state SUPPRESSED while a distinct unconsumed/gap # state STILL re-enumerates + flags (G3 teeth intact; no over-suppression). (#932) # # Uses FAKE/STUB sources only (no live network). Isolated per test. # shellcheck disable=SC2030,SC2031 set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" RECON="$SCRIPT_DIR/reconcile.sh" STORE="$SCRIPT_DIR/store.sh" DET="$SCRIPT_DIR/detector.sh" command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2 exit 0 } TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT FAILFILE="$TMP_ROOT/failures" : >"$FAILFILE" pass=0 fail_msg() { echo " FAIL: $*" >&2 echo "x" >>"$FAILFILE" } ok() { pass=$((pass + 1)); } fresh_state() { local d="$TMP_ROOT/$1" rm -rf "$d" mkdir -p "$d" printf '%s' "$d" } depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; } # make_stub DIR — a source adapter reading $DIR/_ with optional .rc. make_stub() { local dir="$1" cat >"$dir/adapter.sh" < PASS ==" ( WAKE_STATE_HOME="$(fresh_state r1)" export WAKE_STATE_HOME unset WAKE_AGENT wl="$TMP_ROOT/r1.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" }, { "id": "r2" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" out="$("$RECON" inventory 2>&1)" rc=$? [ "$rc" -eq 0 ] || fail_msg "R1: a complete inventory must PASS (exit 0) [$out]" echo "$out" | grep -qi 'COMPLETE' || fail_msg "R1: a complete inventory must report COMPLETE [$out]" ) && ok echo "== R2: OMITTED source -> FLAG (vacuous-pass prevented, not silently green) ==" ( WAKE_STATE_HOME="$(fresh_state r2)" export WAKE_STATE_HOME unset WAKE_AGENT # r2 is DECLARED (operationally depended-on) but no watch covers it. wl="$TMP_ROOT/r2.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" }, { "id": "r2" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" out="$("$RECON" inventory 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R2: an omitted (declared-but-unwatched) source must FLAG (non-zero), not silently pass" echo "$out" | grep -qi 'r2' || fail_msg "R2: the flag must name the omitted source r2 [$out]" echo "$out" | grep -qi 'vacuous' || fail_msg "R2: the flag must state the vacuous-pass is prevented [$out]" ) && ok echo "== R3: required_sources omission -> FLAG ==" ( WAKE_STATE_HOME="$(fresh_state r3)" export WAKE_STATE_HOME unset WAKE_AGENT # The lane explicitly declares it depends on r1 AND r2, but only watches r1. wl="$TMP_ROOT/r3.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" }, { "id": "r2" } ], "watches": [ { "lane": "L", "required_sources": [ "r1", "r2" ], "sources": [ { "kind": "repo", "id": "r1" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" out="$("$RECON" inventory 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R3: a required_sources omission must FLAG (non-zero)" echo "$out" | grep -qi "requires source 'r2'" || fail_msg "R3: the flag must name the required-but-omitted source r2 [$out]" ) && ok echo "== R4: dangling watch reference -> FLAG ==" ( WAKE_STATE_HOME="$(fresh_state r4)" export WAKE_STATE_HOME unset WAKE_AGENT wl="$TMP_ROOT/r4.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r9" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" out="$("$RECON" inventory 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R4: a dangling reference must FLAG (non-zero)" echo "$out" | grep -qi 'dangling' || fail_msg "R4: the flag must state it is dangling [$out]" ) && ok echo "== R5/R6: pre-existing state -> UNACCOUNTED flag + enumerated into store; re-run 0 ==" ( WAKE_STATE_HOME="$(fresh_state r56)" export WAKE_STATE_HOME unset WAKE_AGENT stub="$TMP_ROOT/r56stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" # TWO pre-existing sources with content already present at startup. printf 'PRE-EXISTING-1\n' >"$stub/repo_r1" printf 'PRE-EXISTING-2\n' >"$stub/repo_r2" wl="$TMP_ROOT/r56.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" }, { "id": "r2" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" # Sole-feeder mode: no detector co-feeds this store (detector.sh is never run # here), so enumeration is collision-safe once the operator asserts it. export WAKE_RECONCILE_ALLOW_ENUMERATE=1 [ "$(depth)" = "0" ] || fail_msg "R5: store must start empty" out="$("$RECON" reconcile 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R5: pre-existing unaccounted state must FLAG (non-zero) on first reconcile" echo "$out" | grep -qi 'UNACCOUNTED=2' || fail_msg "R5: both pre-existing sources must be UNACCOUNTED [$out]" # R6: enumerated into the durable store — one entry per pre-existing source. [ "$(depth)" = "2" ] || fail_msg "R6: pre-existing state must be ENUMERATED into the store (depth 2), got $(depth)" # A follow-up reconcile now finds everything accounted -> 0 unaccounted. out2="$("$RECON" reconcile 2>&1)" rc2=$? [ "$rc2" -eq 0 ] || fail_msg "R6: a second reconcile must report 0 unaccounted (exit 0) [$out2]" echo "$out2" | grep -qi 'UNACCOUNTED=0' || fail_msg "R6: second reconcile must be 0 unaccounted [$out2]" [ "$(depth)" = "2" ] || fail_msg "R6: a clean reconcile must NOT re-enumerate (depth still 2), got $(depth)" ) && ok echo "== R7: source already in the inbox -> ACCOUNTED (no double-enumeration) ==" ( WAKE_STATE_HOME="$(fresh_state r7)" export WAKE_STATE_HOME unset WAKE_AGENT stub="$TMP_ROOT/r7stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" printf 'V0\n' >"$stub/repo_r1" wl="$TMP_ROOT/r7.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" # Detector baselines (silent) then observes a real delta -> pending inbox entry. "$DET" poll-once >/dev/null 2>&1 || fail_msg "R7: detector baseline failed" printf 'V1\n' >"$stub/repo_r1" "$DET" poll-once >/dev/null 2>&1 || fail_msg "R7: detector delta failed" [ "$(depth)" = "1" ] || fail_msg "R7: detector delta should leave one pending entry, got $(depth)" # The source's CURRENT state IS the pending inbox entry -> ACCOUNTED, and the # reconciler must NOT enumerate a duplicate. out="$("$RECON" reconcile 2>&1)" rc=$? [ "$rc" -eq 0 ] || fail_msg "R7: state reflected in the inbox must be ACCOUNTED (exit 0) [$out]" echo "$out" | grep -qi 'UNACCOUNTED=0' || fail_msg "R7: inbox-reflected state must be 0 unaccounted [$out]" [ "$(depth)" = "1" ] || fail_msg "R7: reconciler must NOT double-enumerate inbox-reflected state (depth still 1), got $(depth)" ) && ok echo "== R8: source adapter error -> FAIL LOUD (G2a; never 'no state') ==" ( WAKE_STATE_HOME="$(fresh_state r8)" export WAKE_STATE_HOME unset WAKE_AGENT stub="$TMP_ROOT/r8stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" printf 'FORBIDDEN\n' >"$stub/repo_r1" printf '3\n' >"$stub/repo_r1.rc" # adapter exits non-zero (403/partial class) wl="$TMP_ROOT/r8.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" out="$("$RECON" reconcile 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R8: a source error must FAIL LOUD (non-zero exit)" echo "$out" | grep -qi 'FAIL LOUD' || fail_msg "R8: the source error must be loud [$out]" [ "$(depth)" = "0" ] || fail_msg "R8: a failed observation must NOT enumerate anything, got depth $(depth)" ) && ok echo "== R9: co-feed NO-ALIAS — detector + reconciler share ONE store allocator; every observed_seq distinct + gapless (#908 resolved) ==" ( WAKE_STATE_HOME="$(fresh_state r9)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE stub="$TMP_ROOT/r9stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" # The reviewer's alpha/beta repro (formerly the dual-allocator ALIASING case). printf 'ALPHA\n' >"$stub/repo_alpha" printf 'BETA\n' >"$stub/repo_beta" wl="$TMP_ROOT/r9.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "alpha" }, { "id": "beta" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "alpha" }, { "kind": "repo", "id": "beta" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" # A DETECTOR is co-feeding this store. Under #908 both the detector and the # reconciler allocate observed_seq from the ONE store cursor (store.sh enqueue, # no --seq), so co-feeding is now SAFE — the reconciler ENUMERATES (no refusal), # and no seq can be aliased. (The old guard failed closed here; that refusal is # retired exactly to the extent the single allocator makes safe.) "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector baseline failed" # A real detector delta on alpha -> the store allocates observed_seq 1. printf 'ALPHA2\n' >"$stub/repo_alpha" "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector delta (alpha) failed" [ "$(depth)" = "1" ] || fail_msg "R9: detector delta should enqueue exactly one entry, got depth $(depth)" # The reconciler co-feeds the SAME store: beta is still unaccounted (its # baseline was silent), so it enumerates beta -> the store allocates the NEXT # seq (2), NOT a reissue of 1. alpha's current state is already in the inbox # (accounted), so it is not double-enumerated. out="$("$RECON" reconcile 2>&1)" rc=$? [ "$rc" -ne 0 ] || fail_msg "R9: pre-existing beta is unaccounted on this pass -> must FLAG (non-zero) [$out]" echo "$out" | grep -qi 'UNACCOUNTED=1' || fail_msg "R9: only beta should be unaccounted (alpha is inbox-accounted) [$out]" [ "$(depth)" = "2" ] || fail_msg "R9: reconciler must ENUMERATE beta into the co-fed store (depth 2), got $(depth)" # A further detector delta on beta must allocate 3 — the unified allocator # advances past the reconciler's seq, it can NEVER reissue/alias 1 or 2. printf 'BETA2\n' >"$stub/repo_beta" "$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector delta (beta) failed" # THE NO-ALIAS ASSERTION: every observed_seq across BOTH feeders is DISTINCT and # the observed prefix is CONTIGUOUS/GAPLESS (1,2,3). Under the OLD two-allocator # design the detector's private counter would have reissued a seq the reconciler # already used, so this set would contain a duplicate (alias). seqs="$("$STORE" drain | jq -r '.observed_seq' | sort -n | tr '\n' ' ')" [ "$seqs" = "1 2 3 " ] || fail_msg "R9: co-fed observed_seqs must be distinct+gapless '1 2 3', got '$seqs' (a duplicate = the aliasing #908 dissolved)" ndistinct="$("$STORE" drain | jq -r '.observed_seq' | sort -nu | grep -c .)" ntotal="$("$STORE" drain | jq -r '.observed_seq' | grep -c .)" [ "$ndistinct" = "$ntotal" ] || fail_msg "R9: aliasing detected — $ntotal entries but only $ndistinct distinct observed_seq" # The contiguous-prefix CONSUMED contract holds over the co-fed seqs. "$STORE" consume --upto 3 >/dev/null 2>&1 || fail_msg "R9: CONSUMED 3 must succeed over the gapless co-fed prefix" ) && ok echo "== R10: #932 — a CONSUMED detector state is ACCOUNTED via the store's last-consumed record (no dup wake, no spurious rc=1 CRITICAL) ==" ( WAKE_STATE_HOME="$(fresh_state r10)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE stub="$TMP_ROOT/r10stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" printf 'V0\n' >"$stub/repo_r1" wl="$TMP_ROOT/r10.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" # Detector baselines (silent) then a real delta -> one pending inbox entry (seq 1). "$DET" poll-once >/dev/null 2>&1 || fail_msg "R10: detector baseline failed" printf 'V1\n' >"$stub/repo_r1" "$DET" poll-once >/dev/null 2>&1 || fail_msg "R10: detector delta failed" [ "$(depth)" = "1" ] || fail_msg "R10: detector delta should leave one pending entry, got $(depth)" # CONSUME it: consumed_seq advances, the pending prefix is truncated. The store # now records r1's last-consumed observed_hash. The source's CURRENT state is # UNCHANGED (still V1) — exactly the pilot's just-consumed byte-match window. "$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "R10: CONSUMED 1 must succeed" [ "$(depth)" = "0" ] || fail_msg "R10: consume must truncate the pending prefix (depth 0), got $(depth)" # RE-RECONCILE. On BASE this RE-ENUMERATES the just-consumed state (inbox # truncated + seen-ledger never covered a detector enqueue) -> UNACCOUNTED=1, # rc=1 (spurious CRITICAL), depth 1 (duplicate wake). After #932 the store's # last-consumed record ACCOUNTS it -> clean. out="$("$RECON" reconcile 2>&1)" rc=$? [ "$rc" -eq 0 ] || fail_msg "R10: a consumed state must be ACCOUNTED (exit 0, no spurious rc=1 CRITICAL) [$out]" echo "$out" | grep -qi 'UNACCOUNTED=0' || fail_msg "R10: a consumed state must be 0 unaccounted (no re-enumeration) [$out]" [ "$(depth)" = "0" ] || fail_msg "R10: reconciler must NOT re-enumerate a consumed state (depth still 0 = no duplicate wake), got $(depth)" ) && ok echo "== R11: #932 — pilot repro: consumed state SUPPRESSED while a distinct unconsumed/gap state STILL re-enumerates (G3 teeth intact; no blanket suppression) ==" ( WAKE_STATE_HOME="$(fresh_state r11)" export WAKE_STATE_HOME unset WAKE_AGENT WAKE_RECONCILE_ALLOW_ENUMERATE stub="$TMP_ROOT/r11stub" mkdir -p "$stub" make_stub "$stub" export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh" printf 'V0\n' >"$stub/repo_r1" # r2 is a genuine unaccounted GAP: present from the start so the detector's # FIRST-SEEN baseline is SILENT (never enqueues it), leaving it unaccounted and # unconsumed — it has NO last-consumed record, so it MUST still re-enumerate. printf 'GAP-STATE\n' >"$stub/repo_r2" wl="$TMP_ROOT/r11.json" cat >"$wl" <<'EOF' { "schema_version": 1, "repos": [ { "id": "r1" }, { "id": "r2" } ], "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" }, { "kind": "repo", "id": "r2" } ] } ] } EOF export WAKE_WATCH_LIST="$wl" # Baseline BOTH sources silently (no enqueue on first-seen). r2 stays at this # state forever -> never enqueued = genuine gap. "$DET" poll-once >/dev/null 2>&1 || fail_msg "R11: detector baseline failed" # r1: a real delta -> enqueued (seq 1) -> CONSUMED (store records its hash). printf 'V1\n' >"$stub/repo_r1" "$DET" poll-once >/dev/null 2>&1 || fail_msg "R11: detector delta (r1) failed" "$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "R11: CONSUMED 1 must succeed" [ "$(depth)" = "0" ] || fail_msg "R11: store should be empty before the reconcile, got depth $(depth)" out="$("$RECON" reconcile 2>&1)" rc=$? # r2 is genuinely unaccounted -> the pass STILL FLAGS (G3 teeth intact). [ "$rc" -ne 0 ] || fail_msg "R11: a genuine gap (r2) must still FLAG (non-zero) [$out]" # After #932: ONLY r2 is unaccounted (r1 suppressed by the store record). On # BASE both r1 and r2 re-enumerate (UNACCOUNTED=2) -> this assertion is red-first. echo "$out" | grep -qi 'UNACCOUNTED=1' || fail_msg "R11: exactly ONE source (the gap r2) must be unaccounted; the consumed r1 must be suppressed [$out]" # Prove precisely WHICH state re-enumerated: the gap r2 IS enumerated, the # consumed r1 is NOT. (base re-enumerates r1 too -> the r1-absent assertion is # red-first; the r2-present assertion holds both before and after = no over-suppression.) en(){ "$STORE" drain | jq -s --arg id "$1" '[ .[] | select(.locators.id==$id) ] | length'; } [ "$(en r2)" -ge 1 ] || fail_msg "R11: the genuine gap r2 MUST be re-enumerated into the store (no blanket suppression) [$out]" [ "$(en r1)" -eq 0 ] || fail_msg "R11: the CONSUMED r1 must NOT be re-enumerated (its byte-matched hash is accounted by the store record) [$out]" ) && ok echo if [ -s "$FAILFILE" ]; then echo "wake reconcile harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 exit 1 fi echo "wake reconcile harness: all invariants passed ($pass groups)"