Warden plan

20 - the inbox counts decisions, not timestamps (issue #347)

← eval suite index


tier: destructive requires: [needs-postgres, needs-server]


20 - the inbox counts decisions, not timestamps (issue #347)

What it proves

The guardian's per-wiki "waiting" count stops being a proxy for dirty_since (a synthesis-only, multi-writer timestamp with no discriminator) and becomes a direct count of undecided citation edges, so a DIRECT edit's fold-ins clear it the moment they save -- no regen required. Per the ruling on #347 (gh issue view 347, koolamusic's verdict comment, "split minimally, option A"):

Prerequisites

Step 1: static -- the anchor, the decision key, and the two exclusions are actually wired

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

# ID-1: the attention/waiting-count query in the inbox module reads
# citationDeclarations, not just dirtySince/lastRebuiltAt. Loose (source-shape)
# guard -- step 2-4 below are the load-bearing live assertions.
if grep -rl "citationDeclarations" server/src/modules/inbox 2>/dev/null | grep -q .; then
  warden_pass "ID-1: citationDeclarations is referenced somewhere in modules/inbox -- the re-anchor looks wired"
else
  warden_fail "ID-1: no reference to citationDeclarations in modules/inbox -- the waiting count still looks anchored purely on the old dirty_since/lastRebuiltAt path"
fi

# ID-2: a 'set-aside' decision literal exists somewhere in server source,
# tied to the edges/attrs vocabulary (not just a UI label).
if grep -rn "set-aside\|setAside" server/src --include="*.ts" | grep -v ".test.ts" | grep -qi "attrs\|decision\|edges"; then
  warden_pass "ID-2: a set-aside decision literal is wired against edges/attrs in server source"
else
  warden_fail "ID-2: no set-aside decision literal found tied to edges/attrs -- the guardian verb looks unwired"
fi

# ID-3: Repair partitioning excludes set-aside.
if grep -q "set-aside\|setAside\|decision" server/src/core/synthesis/partition.ts 2>/dev/null; then
  warden_pass "ID-3: partition.ts references a decision/set-aside exclusion"
else
  warden_fail "ID-3: server/src/core/synthesis/partition.ts has no decision/set-aside exclusion -- Repair may still weave set-aside signals in"
fi

# ID-4: edit grounding excludes set-aside.
if grep -q "set-aside\|setAside\|decision" server/src/agent/cited-signals.ts 2>/dev/null; then
  warden_pass "ID-4: cited-signals.ts references a decision/set-aside exclusion"
else
  warden_fail "ID-4: server/src/agent/cited-signals.ts has no decision/set-aside exclusion -- edit grounding may still ground on set-aside signals"
fi

# ID-5 (negative): editorialStateOf stays a pure function of state/dirtySince/
# lastRebuiltAt -- no citation/attrs/decision input snuck into the dot.
STATE_FN=server/src/lib/wiki-editorial-state.ts
if [ -f "$STATE_FN" ] && grep -qi "decision\|setAside\|citationDeclarations" "$STATE_FN"; then
  warden_fail "ID-5 (negative): $STATE_FN now references decision/citation state -- the editorial dot must stay derived from dirtySince alone, per the ruling"
else
  warden_pass "ID-5 (negative): $STATE_FN carries no decision/citation input -- the dot stays a pure dirtySince function"
fi

Step 2: fixture -- N freshly-attached, uncited signals on a real wiki (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"

APP_BASE="${APP_URL:-http://localhost:8080}"
JAR="$(mktemp /tmp/warden-inbox-decisions-cookies-XXXXXX.txt)"
curl -s -o /dev/null -c "$JAR" -X POST -H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
  -d '{"email":"andrew@robin.ai","password":"robin2026"}' \
  "$APP_BASE/api/auth/sign-in/email" >/dev/null
export WARDEN_AUTH_STRATEGY=cookie-session
export WARDEN_AUTH_COOKIE_JAR="$JAR"

WS_ID=$(warden_authed_curl "$APP_BASE/api/workspaces" | jq -r '.workspaces[0].id')
[ -n "$WS_ID" ] && [ "$WS_ID" != "null" ] \
  && warden_pass "resolved a workspace to fixture against ($WS_ID)" \
  || warden_fail "could not resolve any workspace for the warden identity"

# A plain wiki with a body already present -- so a later direct edit is a
# fold-in against existing prose, not the first-ever Seed.
WIKI_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg n "Warden Inbox Decisions $(date +%s)" --arg w "$WS_ID" \
        '{name:$n, workspaceId:$w, scope:{kind:"workspace"}, type:"log", content:"Baseline body, nothing cited yet."}')" \
  "$APP_BASE/api/wikis" | jq -r '.id')
WIKI_SLUG=$(warden_authed_curl "$APP_BASE/api/wikis/$WIKI_ID" | jq -r '.slug')
[ -n "$WIKI_ID" ] && [ "$WIKI_ID" != "null" ] && [ -n "$WIKI_SLUG" ] && [ "$WIKI_SLUG" != "null" ] \
  && warden_pass "fixture wiki created ($WIKI_ID / $WIKI_SLUG)" \
  || warden_fail "could not create the fixture wiki"

# The warden identity is the org super admin, so the HTTP create derives a
# COMPANY wiki (author_id NULL, no author guardian grant) — and /inbox/attention
# only shows wikis the caller CONTROLS. Grant the warden identity guardianship
# explicitly (the real admin route), so the attention assertions target the
# controlling-guardian surface the ruling is about.
ME_ID=$(warden_authed_curl "$APP_BASE/api/auth/get-session" | jq -r '.user.id // empty')
GRANT_CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg w "$WIKI_ID" --arg u "$ME_ID" '{wikiId:$w, userId:$u}')" \
  "$APP_BASE/api/org/guardians")
[[ "$GRANT_CODE" =~ ^2 ]] \
  && warden_pass "warden identity granted guardianship of the fixture wiki" \
  || warden_fail "could not grant the warden identity guardianship of the fixture wiki (HTTP $GRANT_CODE) -- attention assertions below will not see the wiki"

# N=3 freshly-attached signals via the real capture path (log_signal against
# this wiki's slug) -- this is what "attached but not woven into prose"
# actually looks like in production: a live SIGNAL_CITED_BY_WIKI edge, body
# untouched, signal absent from citationDeclarations.
declare -a SIGNAL_IDS=()
for i in 1 2 3; do
  RESP=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg c "Warden fixture signal $i $(date +%s%N)" --arg s "$WIKI_SLUG" \
          '{content:$c, wikiSlug:$s}')" \
    "$APP_BASE/api/signals/log")
  SID=$(echo "$RESP" | jq -r '.signalKey // .signalId // .id // empty')
  SIGNAL_IDS+=("$SID")
done
NONEMPTY=$(printf '%s\n' "${SIGNAL_IDS[@]}" | grep -c '.')
[ "$NONEMPTY" = "3" ] \
  && warden_pass "3 fixture signals logged and cited against the wiki" \
  || warden_fail "expected 3 signal ids from log_signal, got $NONEMPTY -- cannot proceed with a clean fixture"

for sid in "${SIGNAL_IDS[@]}"; do echo "$sid"; done > /tmp/warden-inbox-decisions-signal-ids
echo "$WIKI_ID" > /tmp/warden-inbox-decisions-wiki-id
echo "$WIKI_SLUG" > /tmp/warden-inbox-decisions-wiki-slug

# Ground truth: 3 live, undecided edges; none of the 3 signal ids appear in
# citationDeclarations yet (log_signal attaches, it does not weave).
EDGE_COUNT=$(warden_psql_one "SELECT count(*) FROM edges WHERE dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND src_id = ANY(ARRAY['${SIGNAL_IDS[0]}','${SIGNAL_IDS[1]}','${SIGNAL_IDS[2]}']) AND deleted_at IS NULL")
[ "${EDGE_COUNT:-0}" = "3" ] \
  && warden_pass "3 live SIGNAL_CITED_BY_WIKI edges exist for the fixture signals" \
  || warden_fail "expected 3 live citation edges, found ${EDGE_COUNT:-0}"

CITED_ALREADY=$(warden_psql_one "SELECT count(DISTINCT s) FROM wikis w, jsonb_array_elements(COALESCE(w.citation_declarations,'[]'::jsonb)) d, jsonb_array_elements_text(d->'signalIds') s WHERE w.lookup_key = '$WIKI_ID' AND s = ANY(ARRAY['${SIGNAL_IDS[0]}','${SIGNAL_IDS[1]}','${SIGNAL_IDS[2]}'])")
[ "${CITED_ALREADY:-0}" = "0" ] \
  && warden_pass "none of the 3 fixture signals appear in citationDeclarations yet -- correctly uncited" \
  || warden_skip "citationDeclarations shape check" "citation_declarations JSON shape differs from the assumed {refs:[{signalId}]} -- re-read the shipped shape before trusting CITED_ALREADY=0 as ground truth; the live count in step 3 is load-bearing regardless"

# The load-bearing outcome check: the guardian attention view shows 3 waiting
# for this wiki. Field name is an implementation choice -- try the plausible
# candidates and take whichever is present.
ATTENTION=$(warden_authed_curl "$APP_BASE/api/inbox/attention")
WAITING=$(echo "$ATTENTION" | jq -r --arg w "$WIKI_ID" \
  '[.wikis[]? // .[]?] | map(select(.id == $w or .wikiId == $w)) | .[0] |
   (.waitingCount // .undecidedCount // .newSignalCount // .signalCount // .attentionCount // "MISSING") | tostring')
[ "$WAITING" = "3" ] \
  && warden_pass "IB-1: guardian attention view shows 3 waiting for the fixture wiki with 3 freshly-attached, uncited signals" \
  || warden_fail "IB-1: expected waiting count 3 for the fixture wiki, got '$WAITING' -- GET /inbox/attention response: $ATTENTION"

Step 3: a DIRECT edit folds K signals in -- the waiting count drops by K immediately, no synthesis (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"
mapfile -t SIGNAL_IDS < /tmp/warden-inbox-decisions-signal-ids

if [ -z "$WIKI_ID" ] || [ "${#SIGNAL_IDS[@]}" -lt 2 ]; then
  warden_skip "direct-edit fold-in check" "step 2 did not produce a fixture wiki with 3 signals"
else
  # Fold in K=2 of the 3 signals via a DIRECT body edit -- the shipped direct
  # edit path is PUT /content/wiki/:key ({frontmatter, body}), and a fold-in
  # is a CITATION MARKER ([[signal:<slug>]]) in the body, not a text paste
  # (processCitations derives declarations from markers -- see
  # direct-edit-citations.dbtest.test.ts). No regenerate/Seed/Repair call
  # anywhere in this step -- proving the count re-anchors on
  # citationDeclarations, not a synthesis-only timestamp.
  SIG1_SLUG=$(warden_authed_curl "$APP_BASE/api/signals/${SIGNAL_IDS[0]}" | jq -r '.slug // empty')
  SIG2_SLUG=$(warden_authed_curl "$APP_BASE/api/signals/${SIGNAL_IDS[1]}" | jq -r '.slug // empty')
  WIKI_META=$(warden_authed_curl "$APP_BASE/api/wikis/$WIKI_ID")
  WIKI_NAME=$(echo "$WIKI_META" | jq -r '.name // "Warden Inbox Decisions"')
  WIKI_TYPE=$(echo "$WIKI_META" | jq -r '.type // "log"')

  warden_authed_curl -o /dev/null -X PUT -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg n "$WIKI_NAME" --arg t "$WIKI_TYPE" --arg a "$SIG1_SLUG" --arg b "$SIG2_SLUG" \
          '{frontmatter:{name:$n, type:$t}, body:("## Notes\n\nBaseline body, edited to fold in [[signal:" + $a + "]] and [[signal:" + $b + "]].")}')" \
    "$APP_BASE/api/content/wiki/$WIKI_ID"
  sleep 1

  # IB-2: citationDeclarations now includes the 2 folded-in signal ids
  # (ground truth, independent of the attention endpoint's field naming).
  FOLDED=$(warden_psql_one "SELECT count(DISTINCT s) FROM wikis w, jsonb_array_elements(COALESCE(w.citation_declarations,'[]'::jsonb)) d, jsonb_array_elements_text(d->'signalIds') s WHERE w.lookup_key = '$WIKI_ID' AND s = ANY(ARRAY['${SIGNAL_IDS[0]}','${SIGNAL_IDS[1]}'])")
  [ "${FOLDED:-0}" -ge "2" ] 2>/dev/null \
    && warden_pass "IB-2: both directly-edited-in signals now appear in citationDeclarations" \
    || warden_skip "citationDeclarations shape re-check" "count came back ${FOLDED:-0}, not >=2 -- re-verify the citation_declarations column shape before treating this as a real miss; IB-3 below is the outcome that actually matters"

  # IB-3: the load-bearing outcome -- waiting count for this wiki dropped
  # from 3 to 1, IMMEDIATELY (no sleep for a queue, no regenerate call ran).
  ATTENTION=$(warden_authed_curl "$APP_BASE/api/inbox/attention")
  WAITING=$(echo "$ATTENTION" | jq -r --arg w "$WIKI_ID" \
    '[.wikis[]? // .[]?] | map(select(.id == $w or .wikiId == $w)) | .[0] |
     (.waitingCount // .undecidedCount // .newSignalCount // .signalCount // .attentionCount // "MISSING") | tostring')
  [ "$WAITING" = "1" ] \
    && warden_pass "IB-3: waiting count dropped from 3 to 1 immediately after a direct edit folded in 2 signals -- no synthesis ran" \
    || warden_fail "IB-3: expected waiting count 1 after folding in 2/3 signals via direct edit, got '$WAITING'"

  echo "${SIGNAL_IDS[2]}" > /tmp/warden-inbox-decisions-remaining-signal-id
fi

Step 4: the guardian sets the remaining signal aside -- count drops by 1, edge stays live and visible (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"
SIGNAL_ID="$(cat /tmp/warden-inbox-decisions-remaining-signal-id 2>/dev/null)"

if [ -z "$WIKI_ID" ] || [ -z "$SIGNAL_ID" ]; then
  warden_skip "set-aside check" "step 3 did not leave a remaining undecided signal"
else
  # The guardian verb's exact route shape is an implementation choice not
  # fixed by the ruling -- try the plausible candidates (symmetric with the
  # existing un-attach verbs: DELETE /wikis/:id/signals/:signalId,
  # POST /signals/:id/detach) and take whichever 2xx's. Record which one
  # worked so step 5's undo can invert it.
  SET_CODE="000"; SET_SHAPE=""
  for CAND in \
    "POST|$APP_BASE/api/wikis/$WIKI_ID/signals/$SIGNAL_ID/set-aside|{}" \
    "POST|$APP_BASE/api/signals/$SIGNAL_ID/set-aside|$(jq -cn --arg w "$WIKI_ID" '{wikiId:$w}')" \
    "PATCH|$APP_BASE/api/wikis/$WIKI_ID/signals/$SIGNAL_ID|{\"decision\":\"set-aside\"}" \
  ; do
    METHOD="${CAND%%|*}"; REST="${CAND#*|}"; URL="${REST%%|*}"; BODY="${REST#*|}"
    CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X "$METHOD" -H 'Content-Type: application/json' -d "$BODY" "$URL")
    if [[ "$CODE" =~ ^2 ]]; then SET_CODE="$CODE"; SET_SHAPE="$CAND"; break; fi
  done
  echo "$SET_SHAPE" > /tmp/warden-inbox-decisions-set-aside-shape
  [[ "$SET_CODE" =~ ^2 ]] \
    && warden_pass "IB-4: a guardian set-aside verb accepted the request ($SET_CODE via ${SET_SHAPE%%|*})" \
    || warden_fail "IB-4: no candidate set-aside route/verb accepted the request (all non-2xx) -- the guardian verb from the ruling is not reachable"
  sleep 1

  # IB-5: waiting count drops by 1 (from 1 to 0).
  ATTENTION=$(warden_authed_curl "$APP_BASE/api/inbox/attention")
  WAITING=$(echo "$ATTENTION" | jq -r --arg w "$WIKI_ID" \
    '[.wikis[]? // .[]?] | map(select(.id == $w or .wikiId == $w)) | .[0] |
     (.waitingCount // .undecidedCount // .newSignalCount // .signalCount // .attentionCount // "0") | tostring')
  [ "$WAITING" = "0" ] \
    && warden_pass "IB-5: waiting count dropped to 0 after set-aside" \
    || warden_fail "IB-5: expected waiting count 0 after set-aside, got '$WAITING'"

  # IB-6 (negative, the load-bearing correctness check): the edge is STILL
  # LIVE -- set-aside must not delete or detach it.
  LIVE=$(warden_psql_one "SELECT count(*) FROM edges WHERE src_id = '$SIGNAL_ID' AND dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND deleted_at IS NULL")
  [ "${LIVE:-0}" = "1" ] \
    && warden_pass "IB-6 (negative): the citation edge is still live after set-aside -- not detached" \
    || warden_fail "IB-6 (negative): expected 1 live SIGNAL_CITED_BY_WIKI edge after set-aside, found ${LIVE:-0} -- set-aside must not delete/detach the edge"

  # IB-7: the edge actually carries the decision key, keyed correctly.
  DECISION=$(warden_psql_one "SELECT attrs->>'decision' FROM edges WHERE src_id = '$SIGNAL_ID' AND dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND deleted_at IS NULL")
  [ "$DECISION" = "set-aside" ] \
    && warden_pass "IB-7: edges.attrs->>'decision' = 'set-aside' on the edge" \
    || warden_fail "IB-7: expected attrs.decision = 'set-aside', got '$DECISION'"

  # IB-8: the signal is still visible on the wiki's signal list (attached, not
  # detached -- distinct from IB-6, this is the guardian-facing surface).
  SIGNALS_LIST=$(warden_authed_curl "$APP_BASE/api/wikis/$WIKI_ID" | jq -r '.signals')
  if echo "$SIGNALS_LIST" | jq -e --arg s "$SIGNAL_ID" 'map(.id) | index($s) != null' >/dev/null 2>&1; then
    warden_pass "IB-8: the set-aside signal is still present on the wiki's signal list"
  else
    warden_fail "IB-8: the set-aside signal is missing from GET /wikis/:id .signals -- it should stay visible, only excluded from the waiting count"
  fi
fi

Step 5: Repair excludes the set-aside signal from the proposal (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"
SIGNAL_ID="$(cat /tmp/warden-inbox-decisions-remaining-signal-id 2>/dev/null)"

if [ -z "$WIKI_ID" ] || [ -z "$SIGNAL_ID" ]; then
  warden_skip "repair-excludes-set-aside check" "step 4 did not produce a set-aside signal"
else
  SIG_TEXT=$(warden_authed_curl "$APP_BASE/api/signals/$SIGNAL_ID" | jq -r '.content // .title // empty')

  warden_authed_curl -o /dev/null -X POST "$APP_BASE/api/wikis/$WIKI_ID/regenerate"
  sleep 3

  SUGGESTION=$(warden_psql_one "SELECT id FROM wiki_suggestions WHERE wiki_id = '$WIKI_ID' AND status = 'pending' ORDER BY created_at DESC LIMIT 1")
  if [ -z "$SUGGESTION" ]; then
    warden_skip "repair-excludes-set-aside check" "no pending wiki_suggestions row appeared after regenerate -- Repair may have no-op'd (only a set-aside signal to weave); re-check with a second non-set-aside signal present before treating this as a real miss"
  else
    PROPOSED=$(warden_psql_one "SELECT content FROM wiki_suggestions WHERE id = '$SUGGESTION'")
    SIG_SNIPPET=$(echo "$SIG_TEXT" | head -c 24)
    if [ -n "$SIG_SNIPPET" ] && echo "$PROPOSED" | grep -qF "$SIG_SNIPPET"; then
      warden_fail "IB-9: the pending Repair suggestion appears to weave in the set-aside signal's content -- set-aside must be excluded from repair partitioning"
    else
      warden_pass "IB-9: the pending Repair suggestion does not weave in the set-aside signal's content"
    fi
  fi
fi

Step 6: undo/clear set-aside -- the signal counts as waiting again (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"
SIGNAL_ID="$(cat /tmp/warden-inbox-decisions-remaining-signal-id 2>/dev/null)"
SET_SHAPE="$(cat /tmp/warden-inbox-decisions-set-aside-shape 2>/dev/null)"

if [ -z "$WIKI_ID" ] || [ -z "$SIGNAL_ID" ]; then
  warden_skip "undo set-aside check" "step 4 did not produce a set-aside signal"
else
  UNDO_CODE="000"
  for CAND in \
    "POST|$APP_BASE/api/signals/$SIGNAL_ID/reconsider|$(jq -cn --arg w "$WIKI_ID" '{wikiId:$w}')" \
    "DELETE|$APP_BASE/api/wikis/$WIKI_ID/signals/$SIGNAL_ID/set-aside|" \
    "POST|$APP_BASE/api/signals/$SIGNAL_ID/clear-set-aside|$(jq -cn --arg w "$WIKI_ID" '{wikiId:$w}')" \
    "PATCH|$APP_BASE/api/wikis/$WIKI_ID/signals/$SIGNAL_ID|{\"decision\":null}" \
  ; do
    METHOD="${CAND%%|*}"; REST="${CAND#*|}"; URL="${REST%%|*}"; BODY="${REST#*|}"
    if [ -n "$BODY" ]; then
      CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X "$METHOD" -H 'Content-Type: application/json' -d "$BODY" "$URL")
    else
      CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X "$METHOD" "$URL")
    fi
    if [[ "$CODE" =~ ^2 ]]; then UNDO_CODE="$CODE"; break; fi
  done
  [[ "$UNDO_CODE" =~ ^2 ]] \
    && warden_pass "IB-10: an undo/clear-set-aside call succeeded ($UNDO_CODE)" \
    || warden_fail "IB-10: no candidate undo route accepted the request (all non-2xx) -- clearing a set-aside decision is required by the ruling"
  sleep 1

  DECISION=$(warden_psql_one "SELECT attrs->>'decision' FROM edges WHERE src_id = '$SIGNAL_ID' AND dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND deleted_at IS NULL")
  [ -z "$DECISION" ] || [ "$DECISION" = "null" ] \
    && warden_pass "IB-11: attrs.decision key is gone/null after undo -- absence means undecided" \
    || warden_fail "IB-11: attrs.decision is still '$DECISION' after undo -- expected the key cleared"

  # Row-gate housekeeping, NOT the outcome under test: step 5's regenerate ran
  # with an empty undecided partition (the only remaining signal was set-aside)
  # and legitimately cleared dirty_since on completion, which hides the row
  # from /inbox/attention entirely (the list is gated on dirty_since IS NOT
  # NULL -- a different axis than the count, per the ruling). Re-stamp
  # dirty_since via psql so the row is visible again and IB-12 can read the
  # COUNT, which is what this step actually asserts.
  # Wait for the async regen to settle (its completion clears dirty_since);
  # bounded so a Repair that parks a pending suggestion (dirty stays set)
  # doesn't stall the step.
  for _i in $(seq 1 18); do
    DIRTY_NOW=$(warden_psql_one "SELECT dirty_since FROM wikis WHERE lookup_key = '$WIKI_ID'")
    [ -z "$DIRTY_NOW" ] && break
    sleep 5
  done
  if [ -z "$DIRTY_NOW" ]; then
    warden_psql_exec "UPDATE wikis SET dirty_since = now() WHERE lookup_key = '$WIKI_ID'"
  fi

  ATTENTION=$(warden_authed_curl "$APP_BASE/api/inbox/attention")
  WAITING=$(echo "$ATTENTION" | jq -r --arg w "$WIKI_ID" \
    '[.wikis[]? // .[]?] | map(select(.id == $w or .wikiId == $w)) | .[0] |
     (.waitingCount // .undecidedCount // .newSignalCount // .signalCount // .attentionCount // "0") | tostring')
  [ "$WAITING" = "1" ] \
    && warden_pass "IB-12: waiting count is back to 1 -- undoing set-aside counts the signal as waiting again" \
    || warden_fail "IB-12: expected waiting count 1 after undoing set-aside, got '$WAITING'"
fi

Step 7: negatives -- no cross-talk, and legacy edges without the key still count as undecided (live + DB)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"
mapfile -t SIGNAL_IDS < /tmp/warden-inbox-decisions-signal-ids

if [ -z "$WIKI_ID" ] || [ "${#SIGNAL_IDS[@]}" -lt 2 ]; then
  warden_skip "cross-talk / legacy-edge negatives" "earlier steps did not produce a usable fixture"
else
  # IB-13 (negative): setting one signal aside must not have altered the
  # citation state of the OTHER two fixture signals (the ones folded in via
  # direct edit in step 3).
  OTHER_DECISIONS=$(warden_psql_one "SELECT count(*) FROM edges WHERE dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND src_id = ANY(ARRAY['${SIGNAL_IDS[0]}','${SIGNAL_IDS[1]}']) AND attrs->>'decision' IS NOT NULL")
  [ "${OTHER_DECISIONS:-0}" = "0" ] \
    && warden_pass "IB-13 (negative): the other 2 fixture signals' citation edges carry no decision -- set-aside on one signal did not bleed onto siblings" \
    || warden_fail "IB-13 (negative): ${OTHER_DECISIONS:-0} sibling edge(s) unexpectedly carry a decision key -- set-aside is leaking across signals"

  # IB-14: a legacy-shaped edge (no attrs at all, or attrs without a decision
  # key) still counts as undecided -- the absence-means-undecided default.
  LEGACY_SIGNAL_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg c "Warden legacy-edge signal $(date +%s%N)" --arg s "$(cat /tmp/warden-inbox-decisions-wiki-slug)" \
          '{content:$c, wikiSlug:$s}')" \
    "$APP_BASE/api/signals/log" | jq -r '.signalKey // .signalId // .id // empty')
  if [ -z "$LEGACY_SIGNAL_ID" ]; then
    warden_skip "legacy-edge undecided check" "could not create a legacy-shaped fixture signal"
  else
    # Strip any attrs the app set on insert, to simulate a pre-#347 edge that
    # predates the decision key entirely.
    warden_psql_exec "UPDATE edges SET attrs = NULL WHERE src_id = '$LEGACY_SIGNAL_ID' AND dst_id = '$WIKI_ID' AND edge_type = 'SIGNAL_CITED_BY_WIKI'"
    ATTENTION=$(warden_authed_curl "$APP_BASE/api/inbox/attention")
    WAITING=$(echo "$ATTENTION" | jq -r --arg w "$WIKI_ID" \
      '[.wikis[]? // .[]?] | map(select(.id == $w or .wikiId == $w)) | .[0] |
       (.waitingCount // .undecidedCount // .newSignalCount // .signalCount // .attentionCount // "0") | tostring')
    [ "$WAITING" = "2" ] \
      && warden_pass "IB-14: a legacy edge with attrs=NULL (no decision key at all) still counts as undecided -- waiting count is 2 (1 prior + this legacy signal)" \
      || warden_fail "IB-14: expected waiting count 2 with a legacy no-attrs edge added, got '$WAITING' -- absence of the decision key must mean undecided"
  fi
fi

Step 8: the editorial dot stays independent of the waiting count (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set -- run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
WIKI_ID="$(cat /tmp/warden-inbox-decisions-wiki-id 2>/dev/null)"

if [ -z "$WIKI_ID" ]; then
  warden_skip "dot-independence check" "no fixture wiki carried through"
else
  # By this point in the run, prior direct edits (step 3) already stamped
  # dirty_since -- confirm it is still non-null (staleness is real and
  # unrelated to the waiting count's current value).
  DIRTY_BEFORE=$(warden_authed_curl "$APP_BASE/api/wikis/$WIKI_ID" | jq -r '.dirtySince')
  [ -n "$DIRTY_BEFORE" ] && [ "$DIRTY_BEFORE" != "null" ] \
    && warden_pass "dirtySince is non-null on the fixture wiki going into this check (prior direct edits stamped it, as expected)" \
    || warden_skip "dot-independence baseline" "dirtySince was already null -- cannot prove independence without a stale wiki; re-run after step 3/6 if this happens"

  # IB-15 (negative, the load-bearing check): emptying the waiting count via
  # set-aside (steps 4-6 above) must NOT itself have cleared dirty_since --
  # staleness and undecided-count are different axes, only Seed/Repair (or
  # accepting a Repair suggestion) close the dirty_since lifecycle.
  DIRTY_AFTER=$(warden_authed_curl "$APP_BASE/api/wikis/$WIKI_ID" | jq -r '.dirtySince')
  [ "$DIRTY_AFTER" = "$DIRTY_BEFORE" ] && [ "$DIRTY_AFTER" != "null" ] \
    && warden_pass "IB-15 (negative): dirtySince is unchanged by the set-aside/undo cycle -- the dot did not flip on its own" \
    || warden_fail "IB-15 (negative): dirtySince moved ('$DIRTY_BEFORE' -> '$DIRTY_AFTER') across a set-aside/undo cycle that touched no body content -- set-aside must not itself affect staleness"
fi

Shape (note for the next author)

Steps 4 and 6 do not assume a fixed route name for the guardian verb -- the ruling commits to the citation-edge/attrs mechanism and "one guardian verb," not to a URL. Both steps try a short list of plausible candidate request shapes (symmetric with the existing un-attach verbs) and take whichever one 2xx's; a hard IB-4/IB-10 fail only fires if NONE of the candidates work, which is a real gap regardless of which shape the shipped code actually used. If the real route differs from all three candidates, add it to the list rather than loosening the pass condition.

Likewise, IB-1/IB-3/IB-5/IB-12/IB-14's jq filters try several plausible field names for the per-wiki waiting count (waitingCount/undecidedCount/newSignalCount/signalCount/attentionCount) on GET /inbox/attention. If the shipped field is named something else entirely, the filter falls through to "MISSING"/"0" and the assertion fails loudly with the raw response body attached (IB-1) -- that failure mode is intentional (surface the drift, don't silently pass), not a plan bug to paper over by widening the candidate list further.

The citationDeclarations shape probe in steps 2/3 (w.citation_declarations ->'refs' with a signalId key per element) is a guess at the existing shape based on processCitations()'s current output -- it is deliberately demoted to warden_skip (not warden_fail) on mismatch, because it is not the outcome the ruling actually promises; IB-1/IB-3 (the live attention count) are the load-bearing checks either way.

Stays manual / not asserted here:

Batch selector

Filename carries the v11-batch prefix segment so bash .warden/run.sh v11-batch (a filename-prefix match, per run.sh's non-phase:/tier: argument resolution) selects this plan and its siblings, without adding an eighth value to the closed tier: vocabulary in .warden/TIERS.md (check-plan-tiers.mjs rejects anything outside the seven canonical labels). tier: destructive / requires: [needs-postgres, needs-server] is this plan's real, checker-honored declaration -- destructive because it creates wikis/signals and mutates edge attrs directly via psql (step 7's legacy-edge simulation).