Files
stack/packages/mosaic/framework/tools/wake/test-wake-reconcile.sh
mosaic-coder 7603cb1c10
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
fix(wake): reconciler fails closed on the observed_seq dual-allocator hazard (#908)
Review of #909 found a blocking correctness defect: the reconciler's
"serialised => safe" claim was FACTUALLY WRONG. observed_seq has two INDEPENDENT
allocators — the detector's private counter ($STATE_DIR/detector/
observed_seq_counter, W4) and the reconciler's store-cursor+offset — so even
STRICTLY SEQUENTIAL calls collide: reconciler enumerates alpha->seq1, beta->seq2
(store observed_seq=2, detector private still 0); the detector's next delta
allocates private 0->1 and enqueues seq1, ALIASING alpha. A consumer acking
CONSUMED 1 then silently DROPS a distinct obligation — the exact G3 swallow the
reconciler exists to prevent, via seq-aliasing. Reproduced with sequential,
never-concurrent calls.

Fix (W5-scoped; full store-side allocator unification stays deferred to #908 —
detector.sh/W4 untouched):
  * Enumeration now FAILS CLOSED. Before writing any observed_seq, the reconciler
    REFUSES (loud, non-zero; the obligation is FLAGGED, never swallowed) when a
    seq it would allocate could be reissued/aliased:
      - if a detector is co-feeding this store (its private state dir exists) —
        ALWAYS refuse (read-only detection; W4 never touched);
      - else require the operator to assert reconciler-SOLE-FEEDER via
        --allow-enumerate / WAKE_RECONCILE_ALLOW_ENUMERATE=1.
    --no-enumerate and `check` never write, so they stay unconditionally safe.
  * Corrected the false "serialised => safe" rationale in the header comment to
    state the real dual-allocator hazard honestly, reference #908, and record the
    operational guard: a detector and the reconciler MUST NOT co-feed one store
    until #908 lands.
  * RED-FIRST test R9 reproduces the serialized-collision scenario (detector
    co-feeding + pre-existing state) and asserts the reconciler FAILS LOUD and
    enqueues NO aliasable seq (depth unchanged). Verified red: removing the guard
    re-enables the collision (R9 depth 0->2) and turns R9 red.

R5/R6 now enumerate under an explicit sole-feeder assertion (no detector
co-feeds those stores). test:framework-shell + shellcheck + firewall all green.

Part of #892

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
2026-07-25 20:52:45 -05:00

282 lines
12 KiB
Bash
Executable File

#!/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)
#
# 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/<kind>_<id> with optional .rc.
make_stub() {
local dir="$1"
cat >"$dir/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"; base="$dir/\${kind}_\${id}"; rc=0
[ -f "\$base.rc" ] && rc="\$(cat "\$base.rc")"
[ -f "\$base" ] && cat "\$base"
exit "\$rc"
EOF
chmod +x "$dir/adapter.sh"
}
echo "== R1: complete inventory -> 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: serialized dual-allocator collision -> reconciler FAILS LOUD (never silent-alias) =="
(
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"
# Pre-existing sources present at startup (the reviewer's alpha/beta repro).
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: baseline it (creates the detector's
# private-counter state dir). The detector's private observed_seq allocator is
# now live and INDEPENDENT of the store cursor — the exact co-feeding hazard.
"$DET" poll-once >/dev/null 2>&1 || fail_msg "R9: detector baseline failed"
# The pre-existing state is unaccounted (baseline is silent). Enumerating it
# here would allocate a store-cursor seq the detector's private counter can
# later REISSUE and alias — the serialized collision. The reconciler MUST
# refuse (fail loud), NOT enqueue a collidable seq.
before="$(depth)"
out="$("$RECON" reconcile 2>&1)"
rc=$?
[ "$rc" -ne 0 ] || fail_msg "R9: enumerating into a detector-co-fed store must FAIL LOUD (non-zero), not silently alias"
echo "$out" | grep -qi 'dual-allocator' || fail_msg "R9: the failure must name the dual-allocator hazard [$out]"
echo "$out" | grep -q '908' || fail_msg "R9: the failure must reference the #908 follow-up [$out]"
echo "$out" | grep -qi 'REFUSED' || fail_msg "R9: the obligation must be REFUSED/flagged, not swallowed [$out]"
# No collidable seq was written: depth is unchanged, so a later detector delta
# cannot alias a reconciler-enumerated entry.
[ "$(depth)" = "$before" ] || fail_msg "R9: a refused reconcile must NOT enqueue any (aliasable) seq, depth changed $before->$(depth)"
# (That the sole-feeder path DOES still enumerate — i.e. the guard is not a
# blanket no-op — is proven by R5/R6, which enumerate under --allow-enumerate.)
) && 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)"