Warden plan

18 - composite child-change dirty cascade + Repair re-weave (issue #224)

← eval suite index


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


18 - composite child-change dirty cascade + Repair re-weave (issue #224)

What it proves

A composite wiki (one that weaves other wikis via a curated, deliberate WIKI_CITES_WIKI reference set — the "contents page" render, D-05/D-06) stops silently drifting out of date when a child it cites changes body, WITHOUT reintroducing the background auto-rewriter the evolution model deleted (#281/#292, D-08). The V1.1 design landed by this issue is narrow and deliberate:

Prerequisites

Step 1: static -- the cascade exists and is wired to a real content-change event, not the dirty-stamp verb alone

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

# CR-1: something in the synthesis/wikis code stamps dirty_since on the
# WIKI_CITES_WIKI *source* (the composite) keyed off the *destination*
# (the child) -- i.e. a query shaped around dstId/srcId over edgeType
# 'WIKI_CITES_WIKI' feeding a dirtySince update, distinct from the existing
# child-of-signal cascade in modules/signals/routes.ts. Loose (source-shape)
# guard only -- step 3 is the load-bearing live assertion.
if grep -rl "WIKI_CITES_WIKI" server/src/core/synthesis server/src/modules/wikis 2>/dev/null \
   | xargs grep -l "dirtySince" 2>/dev/null | grep -q .; then
  warden_pass "CR-1: a WIKI_CITES_WIKI-keyed dirtySince stamp exists in synthesis/wikis code"
else
  warden_fail "CR-1: no code ties WIKI_CITES_WIKI to a dirtySince stamp -- the upward cascade looks unwired"
fi

# CR-2: no producer enqueues a regen job as a SIDE EFFECT of the cascade
# itself (the stamp must stay a marker, not a trigger) -- the only two
# legitimate enqueue call sites remain the explicit ones (manual regenerate
# route, regen_now).
if grep -rn "enqueueWikiRegen(" server/src/core/synthesis server/src/modules/wikis server/src/modules/signals --include='*.ts' 2>/dev/null \
   | grep -v '\.test\.ts' | grep -v "routes.ts\|handlers.ts" | grep -vE ':[0-9]+:[[:space:]]*(//|\*)' | grep -q .; then
  warden_fail "CR-2: enqueueWikiRegen is called from somewhere other than the known explicit-trigger routes/handlers -- the dirty cascade may have grown a silent auto-regen side door"
else
  warden_pass "CR-2: no unexpected enqueueWikiRegen call site -- the cascade stays marker-only"
fi

Step 2: fixture -- a composite deliberately citing one child, plus an unrelated co-occurring 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-reweave-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 CHILD wiki. Wiki creation requires a mandatory scope (#169) and a type
# from the workspace's wiki_types registry ('note' is not one); content is
# not writable at create -- authored bodies are bootstrapped via SQL below,
# deliberately NOT via the Seed/accept events this plan is testing.
CHILD_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg n "Warden Reweave Child $(date +%s)" '{name:$n, type:"log", scope:{kind:"workspace"}}')" \
  "$APP_BASE/api/wikis" | jq -r '.id')

CHILD_SLUG=$(warden_authed_curl "$APP_BASE/api/wikis/$CHILD_ID" | jq -r '.slug')
COMPOSITE_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg n "Warden Reweave Composite $(date +%s)" '{name:$n, type:"log", scope:{kind:"workspace"}, composite:true}')" \
  "$APP_BASE/api/wikis" | jq -r '.id')

# An UNRELATED wiki that will only ever co-occur (shares no deliberate edge)
# -- the negative fixture for step 4.
UNRELATED_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg n "Warden Reweave Unrelated $(date +%s)" '{name:$n, type:"log", scope:{kind:"workspace"}}')" \
  "$APP_BASE/api/wikis" | jq -r '.id')

[ -n "$CHILD_ID" ] && [ "$CHILD_ID" != "null" ] && [ -n "$COMPOSITE_ID" ] && [ "$COMPOSITE_ID" != "null" ] \
  && [ -n "$UNRELATED_ID" ] && [ "$UNRELATED_ID" != "null" ] \
  && warden_pass "fixture child ($CHILD_ID), composite ($COMPOSITE_ID), unrelated ($UNRELATED_ID) wikis created" \
  || warden_fail "could not create the fixture wikis over the API"

# Authored-body bootstrap (SQL): there is no direct authored-edit HTTP
# endpoint -- wikis.content is only written by the Seed persist and the
# accepted-suggestion apply, which are exactly the events under test.
warden_psql_exec "UPDATE wikis SET content = 'Original child body: the widget ships in blue.' WHERE lookup_key = '$CHILD_ID'"
warden_psql_exec "UPDATE wikis SET content = 'Unrelated content, never referenced.' WHERE lookup_key = '$UNRELATED_ID'"

# The deliberate reference: composite cites child via the real references API
# (this is what creates the live WIKI_CITES_WIKI edge).
warden_authed_curl -o /dev/null -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg w "$CHILD_ID" '{wikiId:$w}')" \
  "$APP_BASE/api/wikis/$COMPOSITE_ID/references"
EDGE_COUNT=$(warden_psql_one "SELECT count(*) FROM edges WHERE src_id = '$COMPOSITE_ID' AND edge_type = 'WIKI_CITES_WIKI' AND dst_id = '$CHILD_ID' AND deleted_at IS NULL")
[ "${EDGE_COUNT:-0}" != "0" ] \
  && warden_pass "live WIKI_CITES_WIKI edge from composite to child (references API)" \
  || warden_fail "no live WIKI_CITES_WIKI edge from composite to child -- cannot exercise the cascade without one"

# Seed the composite once (bakes the contents page) so step 5's regenerate
# lands in Repair mode against a non-empty body. Async job -- poll.
warden_authed_curl -o /dev/null -X POST "$APP_BASE/api/wikis/$COMPOSITE_ID/regenerate"
COMPOSITE_BODY=""
for _i in $(seq 1 45); do
  COMPOSITE_BODY=$(warden_psql_one "SELECT COALESCE(content,'') FROM wikis WHERE lookup_key = '$COMPOSITE_ID'")
  [ -n "$COMPOSITE_BODY" ] && break
  sleep 2
done
[ -n "$COMPOSITE_BODY" ] \
  && warden_pass "composite Seed baked a non-empty contents page (Repair-eligible)" \
  || warden_fail "composite Seed did not produce a body within 90s -- step 5 cannot exercise Repair"

# Clear dirtySince so the fixture starts CLEAN before step 3 changes the child.
warden_psql_exec "UPDATE wikis SET dirty_since = NULL WHERE lookup_key IN ('$COMPOSITE_ID','$UNRELATED_ID')"

echo "$CHILD_ID" > /tmp/warden-reweave-child-id
echo "$CHILD_SLUG" > /tmp/warden-reweave-child-slug
echo "$COMPOSITE_ID" > /tmp/warden-reweave-composite-id
echo "$UNRELATED_ID" > /tmp/warden-reweave-unrelated-id

Step 3: the cascade -- child body change dirties the citing composite (live, the load-bearing check)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
CHILD_ID="$(cat /tmp/warden-reweave-child-id 2>/dev/null)"
COMPOSITE_ID="$(cat /tmp/warden-reweave-composite-id 2>/dev/null)"

if [ -z "$CHILD_ID" ] || [ -z "$COMPOSITE_ID" ]; then
  warden_skip "cascade check" "step 2 did not produce the fixture wikis"
else
  BEFORE=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.dirtySince')
  [ "$BEFORE" = "null" ] \
    && warden_pass "composite starts clean (dirtySince null) before the child edit" \
    || warden_fail "composite fixture did not start clean -- dirtySince was '$BEFORE' before the child edit; step 2's setup is unreliable"

  # Change the child's body through a REAL content-change event: an accepted
  # suggestion (there is no direct authored-edit HTTP endpoint -- the two
  # wikis.content writers are the Seed persist and the accepted-suggestion
  # apply). The pending row is fixture (SQL, deterministic text + correct
  # base_hash); the ACCEPT is the live HTTP event whose transaction must
  # stamp the citing composite.
  CHILD_WS=$(warden_psql_one "SELECT workspace_id FROM wikis WHERE lookup_key = '$CHILD_ID'")
  ANDREW_ID=$(warden_psql_one "SELECT id FROM users WHERE email = 'andrew@robin.ai'")
  BASE_HASH=$(printf '%s' 'Original child body: the widget ships in blue.' | sha256sum | cut -d' ' -f1)
  CHILD_SUG="wardensug$(date +%s)"
  warden_psql_exec "INSERT INTO wiki_suggestions (id, workspace_id, wiki_id, proposed_by_user_id, status, rationale, patch, citations, base_hash) VALUES ('$CHILD_SUG', '$CHILD_WS', '$CHILD_ID', '$ANDREW_ID', 'pending', 'warden fixture: child body revision', 'Changed child body: the widget now ships in red.', '[]', '$BASE_HASH')"
  ACCEPT_CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST "$APP_BASE/api/inbox/suggestions/$CHILD_SUG/accept")
  [ "$ACCEPT_CODE" = "200" ] \
    && warden_pass "child body changed via a real accepted suggestion (200)" \
    || warden_fail "accepting the child's suggestion returned $ACCEPT_CODE -- the child body-change event never happened"
  sleep 1

  AFTER=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.dirtySince')
  if [ "$AFTER" != "null" ] && [ -n "$AFTER" ]; then
    warden_pass "CR-3: the citing composite's dirtySince got stamped after its deliberately-referenced child's body changed"
  else
    warden_fail "CR-3: the composite's dirtySince is STILL null after a deliberately-cited child's body changed -- the upward cascade did not fire"
  fi

  # NEGATIVE: the composite's own content must NOT have been rewritten by
  # the dirty stamp -- the cascade is a marker, never an automatic re-weave.
  COMPOSITE_BODY=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.wikiContent // .content')
  if echo "$COMPOSITE_BODY" | grep -qi "red"; then
    warden_fail "CR-4 (negative): the composite's body already mentions the child's NEW content -- something auto-rewrote it off the dirty stamp; the cascade must stay marker-only, no silent re-weave"
  else
    warden_pass "CR-4 (negative): the composite's body is unchanged by the dirty stamp alone -- no silent auto-rewrite"
  fi
fi

Step 4: negative -- a merely co-occurring wiki's body change does NOT dirty the composite

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
UNRELATED_ID="$(cat /tmp/warden-reweave-unrelated-id 2>/dev/null)"
COMPOSITE_ID="$(cat /tmp/warden-reweave-composite-id 2>/dev/null)"

if [ -z "$UNRELATED_ID" ] || [ -z "$COMPOSITE_ID" ]; then
  warden_skip "deliberate-only negative check" "step 2 did not produce the fixture wikis"
else
  # Step 3 legitimately dirtied the composite (CR-3); reset to clean so this
  # negative check isolates the unrelated edit. SQL bootstrap, same as step 2.
  warden_psql_exec "UPDATE wikis SET dirty_since = NULL WHERE lookup_key = '$COMPOSITE_ID'"

  # Same real content-change event as step 3 (accepted suggestion), on the
  # wiki the composite does NOT cite.
  UNREL_WS=$(warden_psql_one "SELECT workspace_id FROM wikis WHERE lookup_key = '$UNRELATED_ID'")
  ANDREW_ID=$(warden_psql_one "SELECT id FROM users WHERE email = 'andrew@robin.ai'")
  UNREL_HASH=$(printf '%s' 'Unrelated content, never referenced.' | sha256sum | cut -d' ' -f1)
  UNREL_SUG="wardenusg$(date +%s)"
  warden_psql_exec "INSERT INTO wiki_suggestions (id, workspace_id, wiki_id, proposed_by_user_id, status, rationale, patch, citations, base_hash) VALUES ('$UNREL_SUG', '$UNREL_WS', '$UNRELATED_ID', '$ANDREW_ID', 'pending', 'warden fixture: unrelated body revision', 'Unrelated content, edited, still never referenced.', '[]', '$UNREL_HASH')"
  warden_authed_curl -o /dev/null -X POST "$APP_BASE/api/inbox/suggestions/$UNREL_SUG/accept"
  sleep 1
  DIRTY=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.dirtySince')
  [ "$DIRTY" = "null" ] \
    && warden_pass "CR-5 (negative): editing a merely-co-occurring, non-referenced wiki did NOT dirty the composite" \
    || warden_fail "CR-5 (negative): the composite got dirtied by a wiki it never deliberately cites -- the cascade is over-firing past the deliberate-only rule"
fi

Step 5: Repair re-weaves as a pending suggestion, never auto-applied (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.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}"
COMPOSITE_ID="$(cat /tmp/warden-reweave-composite-id 2>/dev/null)"

if [ -z "$COMPOSITE_ID" ]; then
  warden_skip "Repair re-weave check" "step 2/3 did not produce a dirtied composite fixture"
else
  BODY_BEFORE=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.wikiContent // .content')

  # Re-stamp dirty (step 4's negative check cleared it) so CR-11's
  # accept-time lifecycle close has real dirt to clear. SQL bootstrap: the
  # stamping EVENT itself is already proven live by CR-3.
  warden_psql_exec "UPDATE wikis SET dirty_since = now() WHERE lookup_key = '$COMPOSITE_ID'"

  warden_authed_curl -o /dev/null -X POST "$APP_BASE/api/wikis/$COMPOSITE_ID/regenerate"
  # Repair is an async job (real model call on this stack) -- poll for the
  # pending suggestion rather than hoping 3 seconds is enough.
  for _i in $(seq 1 60); do
    SUGGESTION=$(warden_psql_one "SELECT id FROM wiki_suggestions WHERE wiki_id = '$COMPOSITE_ID' AND status = 'pending' ORDER BY created_at DESC LIMIT 1")
    [ -n "$SUGGESTION" ] && break
    sleep 2
  done

  BODY_AFTER=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.wikiContent // .content')
  [ "$BODY_BEFORE" = "$BODY_AFTER" ] \
    && warden_pass "CR-6: Repair did NOT write wikis.content directly -- the composite's stored body is unchanged" \
    || warden_fail "CR-6: the composite's stored body changed from a bare regenerate call -- Repair must land as a pending suggestion, never a direct write"

  if [ -n "$SUGGESTION" ]; then
    warden_pass "CR-7: a pending wiki_suggestions row exists for the composite's Repair ($SUGGESTION)"
    PROPOSED=$(warden_psql_one "SELECT patch FROM wiki_suggestions WHERE id = '$SUGGESTION'")
    if echo "$PROPOSED" | grep -qi "red"; then
      warden_pass "CR-8: the pending Repair suggestion reflects the child's CURRENT (post-change) content, not a stale snapshot"
    else
      echo "--- CR-8 proposed patch (for judgment) ---"
      echo "$PROPOSED"
      echo "--- child body at repair time ---"
      warden_psql_one "SELECT content FROM wikis WHERE lookup_key = '$CHILD_ID'"
      warden_fail "CR-8: the pending Repair suggestion does not mention the child's updated content -- the re-weave is reading a stale child body"
    fi
  else
    warden_fail "CR-7: no pending wiki_suggestions row for the composite after regenerate -- Repair may have applied directly or done nothing"
  fi

  # NEGATIVE: nothing auto-accepted it (D38 gate must not touch a composite Repair row).
  STILL_PENDING=$(warden_psql_one "SELECT status FROM wiki_suggestions WHERE id = '$SUGGESTION'")
  [ "$STILL_PENDING" = "pending" ] \
    && warden_pass "CR-9 (negative): the composite Repair suggestion is still pending -- nothing auto-accepted it" \
    || warden_fail "CR-9 (negative): the composite Repair suggestion status is '$STILL_PENDING', not pending -- something auto-applied it"
fi

Step 6: an explicit accept folds the re-weave in, and clears dirty_since (live)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.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}"
COMPOSITE_ID="$(cat /tmp/warden-reweave-composite-id 2>/dev/null)"

if [ -z "$COMPOSITE_ID" ]; then
  warden_skip "accept-folds-in check" "no fixture composite carried through"
else
  SUGGESTION=$(warden_psql_one "SELECT id FROM wiki_suggestions WHERE wiki_id = '$COMPOSITE_ID' AND status = 'pending' ORDER BY created_at DESC LIMIT 1")
  if [ -z "$SUGGESTION" ]; then
    warden_skip "accept-folds-in check" "no pending suggestion carried through from step 5"
  else
    ACCEPT_CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST "$APP_BASE/api/inbox/suggestions/$SUGGESTION/accept")
    [ "$ACCEPT_CODE" = "200" ] \
      && warden_pass "explicit accept on the composite Repair suggestion succeeded (200)" \
      || warden_fail "explicit accept on the composite Repair suggestion returned $ACCEPT_CODE, expected 200"

    BODY=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.wikiContent // .content')
    if echo "$BODY" | grep -qi "red"; then
      warden_pass "CR-10: accepting the Repair suggestion folded the re-woven (post-child-change) content into the composite's body"
    else
      echo "--- CR-10 composite body after accept (for judgment) ---"
      echo "$BODY"
      warden_fail "CR-10: the composite's body after accept still doesn't reflect the child's changed content"
    fi

    DIRTY=$(warden_authed_curl "$APP_BASE/api/wikis/$COMPOSITE_ID" | jq -r '.dirtySince')
    [ "$DIRTY" = "null" ] \
      && warden_pass "CR-11: dirtySince cleared once the Repair fold-in landed" \
      || warden_fail "CR-11: dirtySince is still '$DIRTY' after the Repair was accepted -- the lifecycle didn't close"
  fi
fi

Step 7: regression floor -- committed synthesis + composite suites stay green

set -uo pipefail
source "$WARDEN_LIB/assert.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}"

if pnpm --filter @robin/server exec vitest run \
     src/core/synthesis/regen.composite.dbtest.test.ts \
     src/core/synthesis/composite-contents.dbtest.test.ts \
     src/core/synthesis/synthesis-modes.dbtest.test.ts \
     src/modules/signals/signals.detach.dbtest.test.ts \
     >/tmp/warden-224-suite.log 2>&1; then
  warden_pass "composite/synthesis regression suite passes (Seed-once, contents-page validation, deliberate-only weave, atomic detach cascade all still hold)"
else
  tail -30 /tmp/warden-224-suite.log
  warden_fail "composite/synthesis regression suite FAILED -- see /tmp/warden-224-suite.log"
fi