#!/usr/bin/env bash
# test-wake-detector.sh — RED-FIRST invariant harness for W4 (EPIC #892):
# the per-host, single-instance, delta-gated DETECTOR daemon (detector.sh, A1).
#
# Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if
# that invariant regresses:
#   D1 no-change poll -> NO enqueue (delta-gated; 0-wasted)          (§1.1)
#   D2 a change -> EXACTLY ONE enqueue with a FRESH observed_seq      (§1.2/§2.4)
#   D3 revert A->B->A across polls -> caught (delta detected)         (§2.4)
#   D4 single-instance flock (2nd instance REFUSES)                   (§1.1)
#   D5 watch-list schema_version out of manifest range -> FAIL LOUD  (Gate B)
#   D6 source error/401/403/ambiguous-empty -> FAIL LOUD,
#      observed_seq NOT advanced (the G2a invariant)                  (§4/G2a)
#   D7 anchor-scoped hashing: edit OUTSIDE the anchor is a no-op;
#      edit INSIDE the anchor is a delta                              (§1.1 (a))
#   D8 MIGRATION-RESTART: post-migration first delta ALLOCATES a seq
#      > consumed via the single store-side allocator — never the
#      silent seq<=consumed no-op the old private counter caused      (#908 arrow 3)
#
# Uses FAKE/STUB sources only (no live network). Isolated: every test runs
# against a fresh WAKE_STATE_HOME temp dir.
#
# SC2030/SC2031 are DELIBERATELY disabled: each test runs in its own ( ) subshell
# and re-exports the per-test env (source-adapter path, watch-list path) so the
# environments are isolated and cannot leak between tests. shellcheck reads the
# re-export-per-subshell idiom as "a change that might be lost" — which is
# exactly the isolation we want, not a bug.
# shellcheck disable=SC2030,SC2031
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
  echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
  exit 97
fi
wake_assert_init
DET="$SCRIPT_DIR/detector.sh"
STORE="$SCRIPT_DIR/store.sh"

command -v jq >/dev/null 2>&1 || {
  echo "SKIP: jq not available" >&2
  exit 0
}
command -v flock >/dev/null 2>&1 || {
  echo "SKIP: flock not available" >&2
  exit 0
}

TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT

# Failures recorded to a FILE (subshell-safe: a subshell cannot mutate a parent
# var, so a var counter would silently swallow failures — the exact anti-pattern
# this harness must never have; mirrors test-wake-store-ack.sh).
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 — pending_depth reported by the store for the current namespace.
depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; }
# observed_seq is the STORE's single source of truth now (#908): the detector
# keeps no private counter, so `detector.sh cursors` reports the store cursors.
# This asserts the SAME invariant the old detector-private counter did (a delta
# advances observed_seq by exactly one; no-change/first-seen/fail-loud do not) —
# just read from the unified store allocator.
det_seq() { "$DET" cursors | sed -n 's/^observed_seq=//p'; }

# write_watchlist FILE VERSION — a minimal valid watch-list with one repo source
# ("r1") plus optionally a board_file with an anchor ("b1").
write_watchlist() {
  local file="$1" ver="$2"
  cat >"$file" <<EOF
{
  "schema_version": $ver,
  "repos": [{ "id": "r1", "remote": "example/repo", "class": "digest" }],
  "board_files": [{ "id": "b1", "path": "BOARD.md", "anchor": "## LANE-X" }],
  "watches": [
    { "lane": "lane-x", "sources": [
      { "kind": "repo", "id": "r1" },
      { "kind": "board_file", "id": "b1" }
    ] }
  ]
}
EOF
}

# A stub SOURCE ADAPTER whose per-source output is read from files under
# $STUB_DIR/<kind>_<id>, and whose exit code is read from
# $STUB_DIR/<kind>_<id>.rc (default 0). No network. The detector invokes it as
# `<cmd> <kind> <id>` with the source def on stdin (ignored here).
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 "== D1: no-change poll -> NO enqueue (delta-gated) =="
(
  WAKE_STATE_HOME="$(fresh_state d1)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d1stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d1.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  # Pass 1: first-seen -> baseline, NO wake.
  "$DET" poll-once || fail_msg "D1: baseline pass should succeed"
  [ "$(depth)" = "0" ] || fail_msg "D1: first-seen must baseline silently (deliver-on-new), got depth $(depth)"
  # Pass 2: identical -> STILL no enqueue.
  "$DET" poll-once || fail_msg "D1: unchanged pass should succeed"
  [ "$(depth)" = "0" ] || fail_msg "D1: no-change poll must NOT enqueue, got depth $(depth)"
  [ "$(det_seq)" = "0" ] || fail_msg "D1: observed_seq must not advance without a delta, got $(det_seq)"
) && ok

echo "== D2: a change -> EXACTLY ONE enqueue with a fresh observed_seq =="
(
  WAKE_STATE_HOME="$(fresh_state d2)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d2stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d2.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null || fail_msg "D2: baseline pass failed" # baseline both
  [ "$(depth)" = "0" ] || fail_msg "D2: baseline should not enqueue"
  # Change ONLY the repo source.
  printf 'SHA-BBB\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null || fail_msg "D2: change pass failed"
  [ "$(depth)" = "1" ] || fail_msg "D2: a single change must yield EXACTLY ONE enqueue, got depth $(depth)"
  [ "$(det_seq)" = "1" ] || fail_msg "D2: observed_seq must advance to 1 on the first delta, got $(det_seq)"
  # The enqueued entry carries a fresh observed_seq and the source locator.
  entry="$("$DET" cursors >/dev/null; "$STORE" drain)"
  echo "$entry" | jq -e 'select(.observed_seq==1 and .locators.kind=="repo" and .locators.id=="r1")' >/dev/null \
    || fail_msg "D2: enqueued entry must have observed_seq=1 and repo/r1 locator [$entry]"
  # Poll again with NO further change -> no second enqueue (still exactly one).
  "$DET" poll-once >/dev/null || fail_msg "D2: post-change no-op pass failed"
  [ "$(depth)" = "1" ] || fail_msg "D2: no new change must NOT add a second enqueue, got depth $(depth)"
  [ "$(det_seq)" = "1" ] || fail_msg "D2: observed_seq must stay 1 with no new delta, got $(det_seq)"
) && ok

echo "== D3: revert A->B->A across polls is caught (delta detected) =="
(
  WAKE_STATE_HOME="$(fresh_state d3)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d3stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d3.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf '## LANE-X\nx\n' >"$stub/board_file_b1"
  # Use only the repo source for a clean A->B->A count.
  printf 'STATE-A\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null || fail_msg "D3: baseline A failed" # baseline A
  printf 'STATE-B\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null || fail_msg "D3: A->B failed" # delta 1
  printf 'STATE-A\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null || fail_msg "D3: B->A failed" # delta 2 (the revert)
  # Two deltas total: A->B and the revert B->A. Neither is swallowed as "no change".
  # (board_file b1 never changed, so it contributes 0.)
  [ "$(det_seq)" = "2" ] || fail_msg "D3: revert A->B->A must produce TWO deltas (observed_seq=2), got $(det_seq)"
) && ok

echo "== D4: single-instance flock (2nd instance refuses) =="
(
  WAKE_STATE_HOME="$(fresh_state d4)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d4stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d4.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\nx\n' >"$stub/board_file_b1"
  lock="$WAKE_STATE_HOME/d4.lock"
  export WAKE_DETECTOR_LOCK="$lock"
  export WAKE_DETECTOR_INTERVAL=60
  # First long-lived instance: loops (interval 60), holds the flock.
  "$DET" run >/dev/null 2>&1 &
  runpid=$!
  # Wait until it has acquired the lock (ready pid marker written post-flock).
  for _ in $(seq 1 50); do
    [ -f "$lock.pid" ] && break
    sleep 0.1
  done
  [ -f "$lock.pid" ] || fail_msg "D4: first instance never signalled lock acquisition"
  # Second instance MUST refuse (non-zero) because the flock is held.
  if "$DET" run --once >/dev/null 2>&1; then
    fail_msg "D4: second instance must REFUSE while the flock is held (per-host single-instance)"
  fi
  kill "$runpid" 2>/dev/null || true
  wait "$runpid" 2>/dev/null || true
  # After the holder exits, a fresh instance may acquire the lock. The kernel's
  # fd/flock release can lag process reaping slightly, so allow a bounded wait
  # (the assertion is that the lock IS released eventually, not instantly).
  reacquired=0
  for _ in $(seq 1 30); do
    if "$DET" run --once >/dev/null 2>&1; then
      reacquired=1
      break
    fi
    sleep 0.1
  done
  [ "$reacquired" = "1" ] || fail_msg "D4: a new instance should acquire the lock once the holder is gone"
) && ok

echo "== D5: watch-list schema_version out of manifest range -> FAIL LOUD =="
(
  WAKE_STATE_HOME="$(fresh_state d5)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d5stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\nx\n' >"$stub/board_file_b1"
  # schema_version 999 is far outside the manifest's supported range.
  wl="$TMP_ROOT/d5.json"
  write_watchlist "$wl" 999
  export WAKE_WATCH_LIST="$wl"
  err="$("$DET" poll-once 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "D5: an out-of-range schema_version must FAIL LOUD (non-zero exit)"
  echo "$err" | has_match -qi 'schema_version' || fail_msg "D5: the failure must name schema_version [$err]"
  echo "$err" | has_match -qi 'range' || fail_msg "D5: the failure must state it is out of the supported range [$err]"
  # And nothing was enqueued / no cursor advance under a rejected watch-list.
  [ "$(depth)" = "0" ] || fail_msg "D5: a rejected watch-list must not enqueue, got depth $(depth)"
  [ "$(det_seq)" = "0" ] || fail_msg "D5: a rejected watch-list must not advance observed_seq, got $(det_seq)"
  # In-range still works (guards against a validator that rejects everything).
  write_watchlist "$wl" 1
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D5: an in-range schema_version must be accepted"
) && ok

echo "== D6: source error / ambiguous-empty -> FAIL LOUD, observed_seq NOT advanced (G2a) =="
(
  WAKE_STATE_HOME="$(fresh_state d6)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d6stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d6.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\nx\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null || fail_msg "D6: baseline failed" # baseline
  seq_before="$(det_seq)"

  # (i) A 403-style source error: adapter exits non-zero. MUST fail loud AND NOT
  # be treated as "no change" AND NOT advance observed_seq.
  printf '3\n' >"$stub/repo_r1.rc" # non-zero exit (privacy/403/partial class)
  printf 'FORBIDDEN\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "D6: a source error must FAIL LOUD (non-zero exit)"
  echo "$err" | has_match -qi 'FAIL LOUD' || fail_msg "D6: the source error must be loud [$err]"
  [ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: a source error must NOT advance observed_seq (got $(det_seq), was $seq_before)"
  [ "$(depth)" = "0" ] || fail_msg "D6: a source error must NOT enqueue, got depth $(depth)"

  # (ii) Ambiguous-empty: adapter exits 0 but with EMPTY output. An empty that
  # might mean "hidden" is never "no change" -> FAIL LOUD, no advance.
  rm -f "$stub/repo_r1.rc"
  : >"$stub/repo_r1" # empty, exit 0
  err="$("$DET" poll-once 2>&1)"
  rc=$?
  [ "$rc" -ne 0 ] || fail_msg "D6: an ambiguous-empty response must FAIL LOUD (non-zero exit)"
  echo "$err" | has_match -qi 'AMBIGUOUS-EMPTY' || fail_msg "D6: ambiguous-empty must be named in the loud failure [$err]"
  [ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: ambiguous-empty must NOT advance observed_seq (got $(det_seq))"
  [ "$(depth)" = "0" ] || fail_msg "D6: ambiguous-empty must NOT enqueue, got depth $(depth)"

  # (iii) Recovery proof: once the source recovers with a REAL new value, the
  # (un-swallowed) change is delivered — the error never masked it as "seen".
  rm -f "$stub/repo_r1.rc"
  printf 'SHA-RECOVERED\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || true
  [ "$(depth)" -ge 1 ] || fail_msg "D6: after recovery the real change must be delivered (not masked by the prior error)"
  [ "$(det_seq)" -gt "$seq_before" ] || fail_msg "D6: observed_seq must advance only now, on the real post-recovery delta"
) && ok

echo "== D7: anchor-scoped hashing (edit outside anchor = no-op; inside = delta) =="
(
  WAKE_STATE_HOME="$(fresh_state d7)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d7stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d7.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  # Keep the repo source static so only the anchored board_file is exercised.
  printf 'SHA-STATIC\n' >"$stub/repo_r1"
  printf '## LANE-A\nalpha\n## LANE-X\ndecision: hold\n## LANE-Z\nzulu\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null || fail_msg "D7: baseline failed" # baseline
  d0="$(det_seq)"
  # Edit OUTSIDE the "## LANE-X" anchor (LANE-A / LANE-Z): must be a NO-OP.
  printf '## LANE-A\nALPHA-CHANGED\n## LANE-X\ndecision: hold\n## LANE-Z\nZULU-CHANGED\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null || fail_msg "D7: out-of-anchor pass failed"
  [ "$(det_seq)" = "$d0" ] || fail_msg "D7: an edit OUTSIDE the anchor must NOT wake (anchor-scoped), got seq $(det_seq)"
  [ "$(depth)" = "0" ] || fail_msg "D7: out-of-anchor edit must NOT enqueue, got depth $(depth)"
  # Edit INSIDE the "## LANE-X" anchor: must be a DELTA.
  printf '## LANE-A\nALPHA-CHANGED\n## LANE-X\ndecision: GO\n## LANE-Z\nZULU-CHANGED\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null || fail_msg "D7: in-anchor pass failed"
  [ "$(det_seq)" -gt "$d0" ] || fail_msg "D7: an edit INSIDE the anchor MUST wake (human-decision file edit caught)"
  [ "$(depth)" -ge 1 ] || fail_msg "D7: in-anchor edit must enqueue, got depth $(depth)"
) && ok

echo "== D8: MIGRATION-RESTART — post-migration first delta ALLOCATES > consumed, never a silent no-op (#908, arrow #3) =="
(
  WAKE_STATE_HOME="$(fresh_state d8)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d8stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  # A watch-list with a SINGLE repo source (clean seq accounting).
  wl="$TMP_ROOT/d8.json"
  cat >"$wl" <<'EOF'
{ "schema_version": 1,
  "repos": [ { "id": "r1", "class": "actionable" } ],
  "watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
  export WAKE_WATCH_LIST="$wl"

  # --- simulate adopt -> migrate -> restart --------------------------------
  # §5 migration seeds the STORE cursors (consumed_seq=N, observed_seq=N) so the
  # already-consumed prefix is preserved. It seeds NO detector-private counter —
  # because after #908 the detector HAS none; the store cursor is the single SoT.
  # This is the exact pilot state that used to silently swallow the first delta.
  sd="$WAKE_STATE_HOME/default"
  mkdir -p "$sd"
  printf '5' >"$sd/consumed_seq"
  printf '5' >"$sd/observed_seq"
  : >"$sd/observed.set" # prefix <=5 fully consumed; live window empty
  : >"$sd/pending.jsonl"

  # "Restart" the detector: fresh detector-local state (no hash yet, and — the
  # killer precondition — NO private observed_seq counter exists).
  printf 'STATE-A\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D8: post-restart baseline pass failed"
  # First-seen baselines silently (deliver-on-new); the store cursor is untouched.
  [ "$(depth)" = "0" ] || fail_msg "D8: baseline must not enqueue, got depth $(depth)"
  [ "$(det_seq)" = "5" ] || fail_msg "D8: migration-seeded observed_seq must be preserved at 5, got $(det_seq)"

  # --- the real, un-consumed obligation: ONE delta -------------------------
  printf 'STATE-B\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D8: post-migration delta pass failed"
  # THE KILLER ASSERTION: the store (sole allocator) allocates 6 = observed(5)+1,
  # which is > consumed(5). Under the OLD private-counter detector this delta got
  # seq 1 (counter restarted at 0) which is <= consumed 5 and was SILENTLY
  # swallowed by store enqueue's seq<=consumed no-op (depth stayed 0). With the
  # unified allocator it CANNOT be swallowed: it enqueues and WOULD wake.
  [ "$(det_seq)" = "6" ] || fail_msg "D8: post-migration first delta must ALLOCATE observed_seq 6 (>consumed 5), got $(det_seq) — a restart-at-0 private counter would give <=5 and be swallowed"
  [ "$(depth)" = "1" ] || fail_msg "D8: post-migration delta must ENQUEUE a real obligation (depth 1), NOT be a silent no-op, got depth $(depth)"
  # And it is genuinely deliverable (a locator-bearing pending entry the consumer
  # would wake on), not swallowed.
  entry="$("$STORE" drain)"
  echo "$entry" | jq -e 'select(.observed_seq==6 and .locators.kind=="repo" and .locators.id=="r1")' >/dev/null \
    || fail_msg "D8: the enqueued post-migration obligation must be a real deliverable at observed_seq=6 [$entry]"
  # Contiguous-prefix contract still holds: CONSUMED 6 succeeds (6 is observed,
  # 5 is the consumed prefix — no interior gap).
  "$STORE" consume --upto 6 >/dev/null 2>&1 || fail_msg "D8: CONSUMED 6 must succeed over the contiguous prefix after the delta"
) && ok

echo "== D9: schema (#925) — optional per-class fallback_cadence is BACKWARD-COMPAT (in-range accepted; out-of-range still FAILS LOUD, Gate B intact) =="
(
  WAKE_STATE_HOME="$(fresh_state d9)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d9stub"
  mkdir -p "$stub"
  make_stub "$stub"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  printf 'SHA-D9\n' >"$stub/repo_r1"
  printf '## LANE-X\nx\n' >"$stub/board_file_b1"
  # A watch-list that EXERCISES the new optional additive field: a per-class
  # `fallback_cadence` under an slos tier, at schema_version 1. Because the field is
  # OPTIONAL + additive, an existing schema_version-1 watch-list stays valid and the
  # detector's Gate B range [schema_min, schema_max] is UNCHANGED — the new field
  # must NOT push the watch-list out of range.
  wl="$TMP_ROOT/d9.json"
  write_fc_watchlist() {
    local file="$1" ver="$2"
    cat >"$file" <<EOF
{
  "schema_version": $ver,
  "slos": {
    "actionable-tier": { "class": "actionable", "fallback_bound": "30m", "fallback_cadence": "1h" }
  },
  "repos": [{ "id": "r1", "remote": "example/repo", "class": "digest" }],
  "board_files": [{ "id": "b1", "path": "BOARD.md", "anchor": "## LANE-X" }],
  "watches": [
    { "lane": "lane-x", "sources": [
      { "kind": "repo", "id": "r1" },
      { "kind": "board_file", "id": "b1" }
    ] }
  ]
}
EOF
  }
  # (a) in-range (schema_version 1) WITH fallback_cadence -> ACCEPTED.
  write_fc_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D9: an in-range watch-list carrying the optional fallback_cadence must be ACCEPTED (additive/backward-compatible)"
  # (b) out-of-range (schema_version 999) WITH the SAME field -> STILL FAILS LOUD.
  write_fc_watchlist "$wl" 999
  err="$("$DET" poll-once 2>&1)"; rc=$?
  [ "$rc" -ne 0 ] || fail_msg "D9: the new field must NOT weaken Gate B — an out-of-range schema_version must still FAIL LOUD (rc=$rc)"
  echo "$err" | has_match -qi 'range' || fail_msg "D9: the out-of-range failure must still state it is out of the supported range [$err]"
) && ok

echo "== D10: snapshot metadata (fd 3, #940) — adapter-attested sha/ts land in the enqueued locators =="
(
  WAKE_STATE_HOME="$(fresh_state d10)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d10stub"
  mkdir -p "$stub"
  # Stub adapter that ALSO writes snapshot metadata out-of-band on fd 3.
  cat >"$stub/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"
base="$stub/\${kind}_\${id}"
[ -f "\$base" ] && cat "\$base"
[ -f "\$base.meta" ] && { cat "\$base.meta" >&3; } 2>/dev/null
exit 0
EOF
  chmod +x "$stub/adapter.sh"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d10.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  printf '{"snapshot_sha":"0123abc4567890def0123abc4567890def012345","snapshot_ts":1753850000}\n' >"$stub/repo_r1.meta"
  "$DET" poll-once >/dev/null || fail_msg "D10: baseline pass failed"
  printf 'SHA-BBB\n' >"$stub/repo_r1"
  "$DET" poll-once >/dev/null || fail_msg "D10: change pass failed"
  entry="$("$STORE" drain)"
  echo "$entry" | jq -e 'select(.locators.snapshot_sha=="0123abc4567890def0123abc4567890def012345" and .locators.snapshot_ts==1753850000)' >/dev/null \
    || fail_msg "D10: enqueued locators must carry the adapter-attested snapshot_sha + snapshot_ts [$entry]"
  # And the metadata must NOT have leaked into the hashed content: an unchanged
  # source with CHANGED metadata is still a no-op (delta gate intact).
  d0="$(det_seq)"
  printf '{"snapshot_sha":"ffff111122223333444455556666777788889999","snapshot_ts":1753860000}\n' >"$stub/repo_r1.meta"
  "$DET" poll-once >/dev/null || fail_msg "D10: metadata-only pass failed"
  [ "$(det_seq)" = "$d0" ] || fail_msg "D10: metadata is OUT-OF-BAND — a metadata-only change must NOT be a delta, got seq $(det_seq)"
) && ok

echo "== D11: snapshot metadata is ADVISORY — malformed metadata is dropped LOUDLY, the wake still fires, no fields emitted =="
(
  WAKE_STATE_HOME="$(fresh_state d11)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d11stub"
  mkdir -p "$stub"
  cat >"$stub/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"
base="$stub/\${kind}_\${id}"
[ -f "\$base" ] && cat "\$base"
[ -f "\$base.meta" ] && { cat "\$base.meta" >&3; } 2>/dev/null
exit 0
EOF
  chmod +x "$stub/adapter.sh"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d11.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D11: baseline pass failed"
  # (a) non-JSON garbage on fd 3.
  printf 'this is not json\n' >"$stub/repo_r1.meta"
  printf 'SHA-BBB\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D11: malformed metadata must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'snapshot' || fail_msg "D11: dropping malformed metadata must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
    && fail_msg "D11: malformed metadata must emit NO snapshot fields [$entry]"
  # (b) valid JSON but a non-hex snapshot_sha -> same: loud drop, wake fires, no fields.
  printf '{"snapshot_sha":"NOT-A-HEX-SHA","snapshot_ts":"also-not-a-number"}\n' >"$stub/repo_r1.meta"
  printf 'SHA-CCC\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D11: rejected snapshot_sha must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'snapshot' || fail_msg "D11: rejecting a bad snapshot_sha must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
    && fail_msg "D11: rejected metadata must emit NO snapshot fields [$entry]"
  [ "$(det_seq)" = "2" ] || fail_msg "D11: both real deltas must still have enqueued (advisory metadata never suppresses a wake), got seq $(det_seq)"
) && ok

echo "== D12: snapshot_ts guards (#940 review §2) — ts requires a sha, a future ts is dropped, an absurd ts cannot reach the comparison =="
(
  WAKE_STATE_HOME="$(fresh_state d12)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d12stub"
  mkdir -p "$stub"
  cat >"$stub/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"
base="$stub/\${kind}_\${id}"
[ -f "\$base" ] && cat "\$base"
[ -f "\$base.meta" ] && { cat "\$base.meta" >&3; } 2>/dev/null
exit 0
EOF
  chmod +x "$stub/adapter.sh"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d12.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D12: baseline pass failed"
  goodsha="0123abc4567890def0123abc4567890def012345"
  # (a) ts WITHOUT sha — the weakest attestation: an unverifiable number with no
  # revision to re-verify against. Dropped loudly; the wake still fires.
  printf '{"snapshot_ts":1753850000}\n' >"$stub/repo_r1.meta"
  printf 'SHA-BBB\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D12: ts-without-sha must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'without a valid snapshot_sha' || fail_msg "D12: dropping ts-without-sha must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
    && fail_msg "D12: ts-without-sha must emit NO snapshot fields [$entry]"
  # (b) valid sha + FUTURE ts — a negative age would read fresher-than-fresh,
  # wrong in the reassuring direction. ts dropped, sha kept (independently verifiable).
  future=$(( $(date +%s) + 9999 ))
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$future" >"$stub/repo_r1.meta"
  printf 'SHA-CCC\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D12: future ts must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'future-skew' || fail_msg "D12: dropping a future ts must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
    || fail_msg "D12: future ts must drop ts but KEEP the sha [$entry]"
  # (c) valid sha + absurdly large ts — must be rejected by the sanity regex BEFORE
  # the shell integer comparison (which would error out and silently keep it).
  printf '{"snapshot_sha":"%s","snapshot_ts":99999999999999999999}\n' "$goodsha" >"$stub/repo_r1.meta"
  printf 'SHA-DDD\n' >"$stub/repo_r1"
  err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D12: absurd ts must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'sane positive epoch' || fail_msg "D12: rejecting an absurd ts must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
    || fail_msg "D12: absurd ts must drop ts but KEEP the sha [$entry]"
  [ "$(det_seq)" = "3" ] || fail_msg "D12: all three real deltas must still have enqueued, got seq $(det_seq)"
) && ok

echo "== D13: WAKE_SNAPSHOT_TS_FUTURE_SLACK is operator input — a malformed knob must fall back to 300 loudly, never kill the poll or invert the guard =="
(
  WAKE_STATE_HOME="$(fresh_state d13)"
  export WAKE_STATE_HOME
  unset WAKE_AGENT
  stub="$TMP_ROOT/d13stub"
  mkdir -p "$stub"
  cat >"$stub/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"
base="$stub/\${kind}_\${id}"
[ -f "\$base" ] && cat "\$base"
[ -f "\$base.meta" ] && { cat "\$base.meta" >&3; } 2>/dev/null
exit 0
EOF
  chmod +x "$stub/adapter.sh"
  export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
  wl="$TMP_ROOT/d13.json"
  write_watchlist "$wl" 1
  export WAKE_WATCH_LIST="$wl"
  printf 'SHA-AAA\n' >"$stub/repo_r1"
  printf '## LANE-X\ndecision: hold\n' >"$stub/board_file_b1"
  "$DET" poll-once >/dev/null 2>&1 || fail_msg "D13: baseline pass failed"
  goodsha="0123abc4567890def0123abc4567890def012345"
  # Every sub-case ships a VALID sha + CURRENT ts: the metadata itself is good,
  # only the operator's knob is broken, so the correct outcome is fallback-and-keep.
  # (a) '300s' — the natural duration-suffix mistake. Under set -u this used to be
  # FATAL inside \$((...)): rc=1, wake never fires. Now: loud fallback, ts kept.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-BBB\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='300s' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: SLACK='300s' must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: malformed slack must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: valid metadata must SURVIVE a malformed knob (fallback, not drop) [$entry]"
  # (b) 'abc' — bare word: under set -u, arithmetic dies on 'abc: unbound variable'.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-CCC\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='abc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: SLACK='abc' must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: non-numeric slack must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: valid metadata must SURVIVE a non-numeric knob [$entry]"
  # (c) negative slack — arithmetic would ACCEPT it and silently invert the guard
  # into deny-all (a CURRENT ts reads as 'future'). Must fall back and keep the ts.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-DDD\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='-99999999' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: negative SLACK must NEVER fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: negative slack must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: negative slack must NOT invert the guard into deny-all [$entry]"
  # (c2) MULTI-LINE knobs — grep's ^...$ anchors bind PER LINE, so a value with
  # an embedded newline ($'300\n8') passed the old regex whole yet is FATAL in
  # \$((...)) ('error token is "8"'; $'300\nabc' dies as unbound variable). The
  # case pattern matches the WHOLE string: both must fall back loudly, keep the ts.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-CC2\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\n8' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\n8) must NEVER fail the poll (rc=$rc) [$err]"
  echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: multi-line slack must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: valid metadata must SURVIVE a multi-line knob [$entry]"
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-CC3\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\nabc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\nabc) must NEVER fail the poll (rc=$rc) [$err]"
  echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: multi-line non-numeric slack must be LOUD on stderr [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: valid metadata must SURVIVE a multi-line non-numeric knob [$entry]"
  # (d2-pre) ZERO-PADDED knobs — shape-valid, radix-hostile. '08' passes the
  # regex but is fatal octal in \$((...)) without the 10# normalization; '0300'
  # silently means 192 (octal), so a ts +250s ahead would be WRONGLY dropped.
  # With 10#: '08' means 8 and survives; '0300' means 300 and the +250s ts is KEPT.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(date +%s)" >"$stub/repo_r1.meta"
  printf 'SHA-DD2\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='08' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: SLACK='08' (octal-fatal without 10#) must NEVER fail the poll (rc=$rc) [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: SLACK='08' with a current ts must keep the metadata [$entry]"
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(( $(date +%s) + 250 ))" >"$stub/repo_r1.meta"
  printf 'SHA-DD3\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='0300' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: SLACK='0300' must not fail the poll (rc=$rc)"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
    || fail_msg "D13: SLACK='0300' must mean 300 (decimal), so a +250s ts is KEPT — octal 192 would have dropped it [$entry]"
  # (d) a VALID knob is still honored: slack=0 with a ts 60s ahead -> future-skew drop.
  printf '{"snapshot_sha":"%s","snapshot_ts":%s}\n' "$goodsha" "$(( $(date +%s) + 60 ))" >"$stub/repo_r1.meta"
  printf 'SHA-EEE\n' >"$stub/repo_r1"
  err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='0' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
  [ "$rc" -eq 0 ] || fail_msg "D13: valid SLACK=0 must not fail the poll (rc=$rc)"
  echo "$err" | has_match -qi 'future-skew' || fail_msg "D13: a valid tightened slack must still reject a future ts [$err]"
  entry="$("$STORE" drain | tail -1)"
  echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
    || fail_msg "D13: valid SLACK=0 must drop the future ts but keep the sha [$entry]"
  [ "$(det_seq)" = "8" ] || fail_msg "D13: all eight real deltas must still have enqueued, got seq $(det_seq)"
) && ok

echo
if [ -s "$FAILFILE" ]; then
  echo "wake detector harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
  exit 1
fi
echo "wake detector harness: all invariants passed ($pass groups)"
