Warden plan

22 - ownership, org transfer, agent gaps, error visibility (issue #365)

← eval suite index


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


22 - ownership, org transfer, agent gaps, error visibility (issue #365)

What it proves

Issue #365 is thirteen fixes grouped by one root cause: a rule or a message exists, but the data or the surface behind it doesn't back it up. This plan is the outcome-level floor for that claim — every predicate below is asserted against the live app (:3000) and its database, not against the diff.

  1. Ownership means deletion. A member can delete a wiki they authored, and only that one — another member's wiki still refuses. The rule that reads "you own this" now ends in an actual DELETE.
  2. Robin-extracted signals carry their human author. A signal the pipeline pulls out of a member's own entry records that member as creator and is manageable by them; and the pre-existing rows recovered by the migration agree with their parent entry's author (spot-check + blanket negative).
  3. A guardian's grant is the authority it looks like. A workspace admin cannot accept changes on a wiki that HAS a guardian; the wiki's guardian still can; and a guardian-less wiki keeps a working accept path (the fallback narrows, it does not disappear).
  4. The org root can change hands, exactly once, and only by its holder. The current super admin transfers; the old holder is demoted; the new one is root; the org has exactly ONE super admin (SQL count). An org admin attempting the same transfer is refused — the load-bearing security negative, because an org admin passes today's manage Member check. And the database itself refuses a second super-admin row (psql probe expecting a unique violation), so a half-applied change can no longer leave two.
  5. The agent surface matches the agent's job. The tool that pulls a wiki body back into the signal graph, and the job-status tool that confirms it finished, are offered to Socrates — it can no longer produce exactly the unsearchable wikis those tools exist to prevent. And capture no longer silently defaults to the root workspace.
  6. A failure says what failed. A server-side refusal on the run-start endpoint returns a readable reason, and the chat client reads the error before it reads runId — so "no active workspace" stops presenting as cannot read properties of undefined. Email is logged against the provider's delivery state rather than its acknowledgement, and the sender address is no longer a hardcoded foreign domain.
  7. Correctness leftovers. Creating a domain twice by the same name is rejected; the create form goes through the cache-invalidating hook so the list it lands on contains the new domain; a settled self-review proposal stays private to its proposer; and the generated API client stops typing nullable fields as non-nullable.

Why tier: destructive

The super-admin transfer step mutates org-root state that every other plan's identity depends on, and the fixture users/wikis persist in the live dev database. Step 9 transfers the root role back to andrew@robin.ai as its last act, and a failure there is reported as an explicit RESTORE assertion — if this plan goes red mid-transfer, read step 9 before running anything else. No DROP SCHEMA / pushTestSchema() here: this runs against Andrew's live dev database through the real API, in plan 20's style.

needs-model is in requires because step 3 waits on the real extraction worker (a live OPENROUTER_API_KEY) to produce a signal. That step degrades to warden_skip on a timeout and hands the load-bearing work to its DB-only sibling (the migration spot-check), so a dry model budget costs coverage, not a false red.

Prerequisites

Step 1: sign in, resolve scope, mint the four fixture identities

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}"

SERVER_BASE="${SERVER_URL:-http://localhost:3000}"
RUN_TS="$(date +%s)"
S=/tmp/warden-365
mkdir -p "$S"
echo "$SERVER_BASE" > "$S/base"; echo "$RUN_TS" > "$S/ts"

export WARDEN_AUTH_STRATEGY=cookie-session
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"

# --- shared helpers, sourced by every later step ---------------------------
cat > "$S/helpers.sh" <<'HELP'
S=/tmp/warden-365
SERVER_BASE="$(cat $S/base)"
export WARDEN_AUTH_STRATEGY=cookie-session

# signin_jar <email> <password> <jar-path> -> echoes the http status
signin_jar() {
  local email="$1" pw="$2" jar="$3"
  : > "$jar"
  curl -s -o /dev/null -w '%{http_code}' -c "$jar" -X POST \
    -H 'Content-Type: application/json' -H "Origin: $SERVER_BASE" \
    -d "$(jq -cn --arg e "$email" --arg p "$pw" '{email:$e,password:$p}')" \
    "$SERVER_BASE/api/auth/sign-in/email"
}

# Workspace scope travels in the x-workspace-id header; without it the server
# resolves the ORG ROOT workspace (C-03 default), where the fixture users hold
# no membership — every scoped call then 403s/404s for harness reasons.
ws_hdr() {
  if [ -f "$S/ws-id" ]; then printf 'x-workspace-id: %s' "$(cat "$S/ws-id")"
  else printf 'x-warden-noop: 1'; fi
}

# as_json <jar> <curl-args...> -> response body
as_json() {
  local jar="$1"; shift
  curl -s -b "$jar" -H "Origin: $SERVER_BASE" -H "$(ws_hdr)" "$@"
}

# as_status <jar> <method> <url> [json-body] -> http status only
as_status() {
  local jar="$1" method="$2" url="$3" body="${4:-}"
  if [ -n "$body" ]; then
    curl -s -o /dev/null -w '%{http_code}' -b "$jar" -H "Origin: $SERVER_BASE" \
      -H "$(ws_hdr)" -H 'Content-Type: application/json' -X "$method" -d "$body" "$url"
  else
    curl -s -o /dev/null -w '%{http_code}' -b "$jar" -H "Origin: $SERVER_BASE" \
      -H "$(ws_hdr)" -X "$method" "$url"
  fi
}

# create_wiki <jar> <name> <workspaceId> -> lookupKey (company/member wiki,
# ownership derived server-side from the CALLER, workspace scope)
create_wiki() {
  as_json "$1" -X POST -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg n "$2" --arg w "$3" \
      '{name:$n, description:"warden 365 fixture", scope:{kind:"workspace"}, composite:false}')" \
    "$SERVER_BASE/wikis" | jq -r '.lookupKey // .id // empty'
}
HELP
# shellcheck disable=SC1090
source "$S/helpers.sh"

JAR_A="$S/jar-andrew"
[ "$(signin_jar andrew@robin.ai robin2026 "$JAR_A")" = "200" ] \
  && warden_pass "signed in as andrew@robin.ai (super admin) against $SERVER_BASE" \
  || warden_fail "sign-in as andrew@robin.ai failed — is dev-server.sh up on $SERVER_BASE and the seed applied?"

ANDREW_ID=$(warden_psql_one "SELECT id FROM users WHERE email='andrew@robin.ai'")
ORG_ID=$(warden_psql_one "SELECT organization_id FROM member WHERE user_id='$ANDREW_ID' LIMIT 1")
WS_ID=$(warden_psql_one "SELECT id FROM workspaces WHERE organization_id='$ORG_ID' AND is_root = false ORDER BY created_at LIMIT 1")
[ -z "$WS_ID" ] && WS_ID=$(warden_psql_one "SELECT id FROM workspaces WHERE organization_id='$ORG_ID' ORDER BY is_root, created_at LIMIT 1")
{ [ -n "$ANDREW_ID" ] && [ -n "$ORG_ID" ] && [ -n "$WS_ID" ]; } \
  && warden_pass "resolved org ($ORG_ID) + a non-root workspace ($WS_ID) to fixture in" \
  || warden_fail "could not resolve andrew's user/org/workspace chain from the DB"
printf '%s\n' "$ANDREW_ID" > "$S/andrew-id"; printf '%s\n' "$ORG_ID" > "$S/org-id"; printf '%s\n' "$WS_ID" > "$S/ws-id"

# mk_user <slot> <org-role> <workspace-role>
# Creates users + credential account (andrew's hash, so `robin2026` signs in)
# + org member row + workspace membership. Writes the id to $S/<slot>-id.
mk_user() {
  local slot="$1" orole="$2" wrole="$3"
  local email="warden365-${slot}-${RUN_TS}@robin.test"
  local uid="wd365${slot}$(printf '%s' "$RUN_TS" | tail -c 6)$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n')"
  warden_psql_exec "INSERT INTO users (id, email, name, email_verified, onboarding_complete, onboarded_at)
    VALUES ('$uid', '$email', 'Warden 365 $slot', true, true, now())"
  # Copy andrew's credential row shape so we inherit whatever NOT NULL columns exist.
  warden_psql_exec "INSERT INTO accounts (id, issuer, account_id, provider_id, user_id, password)
    SELECT '$uid-acct', a.issuer, '$uid', a.provider_id, '$uid', a.password
    FROM accounts a JOIN users u ON u.id = a.user_id
    WHERE u.email='andrew@robin.ai' AND a.password IS NOT NULL LIMIT 1" \
  || warden_psql_exec "INSERT INTO accounts (id, account_id, provider_id, user_id, password)
    SELECT '$uid-acct', '$uid', a.provider_id, '$uid', a.password
    FROM accounts a JOIN users u ON u.id = a.user_id
    WHERE u.email='andrew@robin.ai' AND a.password IS NOT NULL LIMIT 1"
  warden_psql_exec "INSERT INTO member (id, organization_id, user_id, role, passcode, created_at)
    VALUES ('$uid-mem', '$ORG_ID', '$uid', '$orole', NULL, now())"
  warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role)
    VALUES ('$uid-wsm', '$WS_ID', '$uid', '$wrole')
    ON CONFLICT (workspace_id, user_id) DO UPDATE SET role='$wrole'"
  printf '%s\n' "$uid" > "$S/$slot-id"
  printf '%s\n' "$email" > "$S/$slot-email"
  local code; code=$(signin_jar "$email" robin2026 "$S/jar-$slot")
  if [ "$code" = "200" ]; then
    warden_pass "fixture identity '$slot' provisioned and signed in (org=$orole, workspace=$wrole)"
  else
    warden_fail "fixture identity '$slot' could not sign in (HTTP $code) — every predicate that uses it will be unreliable"
  fi
}

mk_user member  member    member          # M   — author/owner under test
mk_user other   member    member          # M2  — the "someone else's wiki" negative
mk_user wsadmin member    workspace_admin # WA  — workspace admin, no guardianship
mk_user orgadmin org_admin member         # OA  — transfer negative + transfer target

Step 2: OWNERSHIP — a member deletes the wiki they authored, and only that one

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
RUN_TS="$(cat $S/ts)"; WS_ID="$(cat $S/ws-id)"
M_JAR="$S/jar-member"; O_JAR="$S/jar-other"; M_ID="$(cat $S/member-id)"

# A member's own wiki: created BY the member, so ownership derives to 'member'
# with author_id = the member (deriveWikiOwnership).
OWN=$(create_wiki "$M_JAR" "Warden 365 Member Own $RUN_TS" "$WS_ID")
FOREIGN=$(create_wiki "$O_JAR" "Warden 365 Other Own $RUN_TS" "$WS_ID")
printf '%s\n' "$OWN" > "$S/wiki-own"; printf '%s\n' "$FOREIGN" > "$S/wiki-foreign"
{ [ -n "$OWN" ] && [ -n "$FOREIGN" ]; } \
  && warden_pass "OD-0: both member-authored fixture wikis exist ($OWN / $FOREIGN)" \
  || warden_fail "OD-0: a member could not create a wiki at all — the ownership predicates below cannot run"

AUTHOR=$(warden_psql_one "SELECT COALESCE(author_id,'') FROM wikis WHERE lookup_key='$OWN'")
[ "$AUTHOR" = "$M_ID" ] \
  && warden_pass "OD-0b: the member's wiki records them as author (author_id=$M_ID)" \
  || warden_fail "OD-0b: wikis.author_id on the member-created wiki is '$AUTHOR', expected '$M_ID' — the ownership signal the delete rule keys on is missing"

# OD-1 (the fix): the author deletes their own wiki. Any 2xx counts — the route
# may answer 200 or 204 — the OUTCOME is what's asserted next.
DEL_CODE=$(as_status "$M_JAR" DELETE "$SERVER_BASE/wikis/$OWN")
case "$DEL_CODE" in
  2*) warden_pass "OD-1: a member deleting the wiki they authored is allowed (HTTP $DEL_CODE)" ;;
  *)  warden_fail "OD-1: a member deleting their OWN wiki got HTTP $DEL_CODE — removal still needs an admin (#365 ownership-and-deletion)" ;;
esac

GONE=$(warden_psql_one "SELECT CASE WHEN deleted_at IS NULL THEN 'live' ELSE 'deleted' END FROM wikis WHERE lookup_key='$OWN'")
[ "$GONE" = "deleted" ] \
  && warden_pass "OD-2: the wiki is actually gone (wikis.deleted_at stamped), not merely 200'd" \
  || warden_fail "OD-2: wikis.deleted_at is still NULL for $OWN — the delete answered but removed nothing"

# OD-3 (the negative that keeps the fix honest): another member's wiki refuses.
FOREIGN_CODE=$(as_status "$M_JAR" DELETE "$SERVER_BASE/wikis/$FOREIGN")
# 404 is this codebase's deliberate existence-masking for out-of-scope rows
# (the delete-scoped read returns nothing) — as much a refusal as a 403.
{ [ "$FOREIGN_CODE" = "403" ] || [ "$FOREIGN_CODE" = "404" ]; } \
  && warden_pass "OD-3: a member deleting ANOTHER member's wiki is refused (HTTP $FOREIGN_CODE)" \
  || warden_fail "OD-3: deleting another member's wiki returned HTTP $FOREIGN_CODE, expected 403/404 — ownership-scoped delete widened into a blanket one"

STILL=$(warden_psql_one "SELECT CASE WHEN deleted_at IS NULL THEN 'live' ELSE 'deleted' END FROM wikis WHERE lookup_key='$FOREIGN'")
[ "$STILL" = "live" ] \
  && warden_pass "OD-4: the other member's wiki survived the refused delete" \
  || warden_fail "OD-4: the other member's wiki was deleted despite the refusal — the route mutates before it authorizes"

Step 3: SIGNAL AUTHORSHIP — Robin's extraction records the entry's author, and the migration recovered the old rows

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
RUN_TS="$(cat $S/ts)"; M_JAR="$S/jar-member"; M_ID="$(cat $S/member-id)"
MARK="WARDEN365SIG${RUN_TS}"

# The member logs a thought of their own. Extraction runs on the queue.
ENTRY=$(as_json "$M_JAR" -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg t "Warden 365 authorship $RUN_TS" --arg c "${MARK}: our Q3 livestock loan book defaulted at 74 percent because origination had no credit scoring, and the drought removed every fallback the borrowers had. Separately, the field team now files weekly moisture readings from all eleven districts." \
    '{title:$t, content:$c, type:"thought"}')" \
  "$SERVER_BASE/entries")
ENTRY_ID=$(echo "$ENTRY" | jq -r '.lookupKey // .id // empty')
[ -n "$ENTRY_ID" ] \
  && warden_pass "SG-0: the member logged an entry through the real capture path ($ENTRY_ID)" \
  || warden_fail "SG-0: POST /entries as the member did not return an entry id: $ENTRY"

# Poll for Robin's extracted signals (real worker + real model: up to ~2min).
SIG_ID=""; n=1
while [ "$n" -le 40 ]; do
  SIG_ID=$(warden_psql_one "SELECT lookup_key FROM signals WHERE entry_id='$ENTRY_ID' AND deleted_at IS NULL ORDER BY created_at LIMIT 1")
  [ -n "$SIG_ID" ] && break
  sleep 3; n=$((n + 1))
done

if [ -z "$SIG_ID" ]; then
  warden_skip "SG-1: no signal was extracted within 120s — extraction worker/model unavailable, so live authorship is unproven (SG-4/SG-5 below still hold the line on the migration)"
  warden_skip "SG-2: skipped with SG-1"
  warden_skip "SG-3: skipped with SG-1"
else
  # SG-1 (the fix): the extracted signal carries the SOURCE ENTRY'S author.
  CREATOR=$(warden_psql_one "SELECT COALESCE(created_by_user_id,'') FROM signals WHERE lookup_key='$SIG_ID'")
  [ "$CREATOR" = "$M_ID" ] \
    && warden_pass "SG-1: the signal Robin extracted from the member's own entry records that member as creator ($M_ID)" \
    || warden_fail "SG-1: signals.created_by_user_id for $SIG_ID is '$CREATOR', expected '$M_ID' — the worker still drops the author it already loaded"

  # SG-2: and therefore the ownership-keyed rules actually reach it.
  UP=$(as_status "$M_JAR" PUT "$SERVER_BASE/signals/$SIG_ID" '{"title":"Warden 365 renamed by its author"}')
  case "$UP" in
    2*) warden_pass "SG-2: the member can update the signal Robin extracted from their own thought (HTTP $UP)" ;;
    *)  warden_fail "SG-2: updating their own extracted signal returned HTTP $UP — a member still can only manage what they typed by hand" ;;
  esac

  DEL=$(as_status "$M_JAR" DELETE "$SERVER_BASE/signals/$SIG_ID")
  case "$DEL" in
    2*) warden_pass "SG-3: the member can delete that same extracted signal (HTTP $DEL)" ;;
    *)  warden_fail "SG-3: deleting their own extracted signal returned HTTP $DEL, expected 2xx" ;;
  esac
fi

# SG-4 (spot-check, DB-only): one PRE-EXISTING extracted signal — created before
# this run, linked to a parent entry that HAS an author — was recovered by the
# migration and now agrees with that entry.
T0="$(date -u -d "@$RUN_TS" +%FT%T 2>/dev/null || date -u -r "$RUN_TS" +%FT%T)"
SPOT=$(warden_psql_one "
  SELECT s.lookup_key FROM signals s
  JOIN raw_sources e ON e.lookup_key = s.entry_id
  WHERE s.created_at < '$T0' AND e.created_by_user_id IS NOT NULL
  ORDER BY s.created_at LIMIT 1")
if [ -z "$SPOT" ]; then
  warden_skip "SG-4: this database holds no pre-migration extracted signal with an authored parent entry to spot-check"
else
  MATCH=$(warden_psql_one "
    SELECT CASE WHEN s.created_by_user_id = e.created_by_user_id THEN 'match'
                WHEN s.created_by_user_id IS NULL THEN 'null'
                ELSE 'mismatch' END
    FROM signals s JOIN raw_sources e ON e.lookup_key = s.entry_id
    WHERE s.lookup_key = '$SPOT'")
  [ "$MATCH" = "match" ] \
    && warden_pass "SG-4: pre-existing extracted signal $SPOT was recovered from its parent entry's author" \
    || warden_fail "SG-4: pre-existing signal $SPOT reports '$MATCH' against its parent entry's author — the backfill did not recover it"
fi

# SG-5 (blanket negative): NO extracted signal with an authored parent is left
# authorless. The spot-check proves one row; this proves the class.
ORPHANS=$(warden_psql_one "
  SELECT count(*) FROM signals s
  JOIN raw_sources e ON e.lookup_key = s.entry_id
  WHERE s.deleted_at IS NULL AND e.created_by_user_id IS NOT NULL
    AND s.created_by_user_id IS NULL")
[ "${ORPHANS:-1}" = "0" ] \
  && warden_pass "SG-5: zero signals with an authored parent entry are missing created_by_user_id" \
  || warden_fail "SG-5: $ORPHANS signal(s) whose parent entry HAS an author still have created_by_user_id NULL — recovery was partial"

Step 4: GUARDIANSHIP — accepting changes is the guardian's right, with the fallback narrowed to guardian-less wikis

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
RUN_TS="$(cat $S/ts)"; WS_ID="$(cat $S/ws-id)"
A_JAR="$S/jar-andrew"; WA_JAR="$S/jar-wsadmin"; M_JAR="$S/jar-member"; O_JAR="$S/jar-other"
M_ID="$(cat $S/member-id)"

# Two COMPANY wikis (created by the super admin, so no author-guardian grant is
# derived): one gets a guardian, one deliberately does not.
W_G=$(create_wiki "$A_JAR" "Warden 365 Guarded $RUN_TS" "$WS_ID")
W_N=$(create_wiki "$A_JAR" "Warden 365 Unguarded $RUN_TS" "$WS_ID")
printf '%s\n' "$W_G" > "$S/wiki-guarded"; printf '%s\n' "$W_N" > "$S/wiki-unguarded"
BODY="Warden 365 baseline body $RUN_TS. REPLACE-ME-MARKER stands here."
warden_psql_exec "UPDATE wikis SET content='$BODY', ownership='company' WHERE lookup_key IN ('$W_G','$W_N')"
{ [ -n "$W_G" ] && [ -n "$W_N" ]; } \
  && warden_pass "GA-0: two company wikis staged with an identical body ($W_G guarded / $W_N guardian-less)" \
  || warden_fail "GA-0: could not create the two fixture wikis"

# The real admin route grants guardianship on W_G to the member; W_N gets none.
# The route's contract is {wikiId, userId} (guardianGrantBodySchema); the
# grant's workspace is derived server-side from the wiki itself.
GRANT=$(as_status "$A_JAR" POST "$SERVER_BASE/org/guardians" \
  "$(jq -cn --arg u "$M_ID" --arg w "$W_G" '{userId:$u, wikiId:$w}')")
GCOUNT=$(warden_psql_one "SELECT count(*) FROM guardian_grants WHERE subject_type='wiki' AND subject_id='$W_G' AND kind='guardian'")
NCOUNT=$(warden_psql_one "SELECT count(*) FROM guardian_grants WHERE subject_type='wiki' AND subject_id='$W_N' AND kind='guardian'")
{ [ "${GCOUNT:-0}" -ge 1 ] && [ "${NCOUNT:-1}" = "0" ]; } \
  && warden_pass "GA-0b: W_G has a guardian ($GCOUNT grant), W_N has none — the two cases the fallback distinguishes" \
  || warden_fail "GA-0b: guardian fixture is wrong (POST /org/guardians -> $GRANT; guarded=$GCOUNT unguarded=$NCOUNT)"

# A plain member with no review rights raises a real pending change on each.
propose() {
  as_json "$O_JAR" -X POST -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg s "REPLACE-ME-MARKER" --arg r "rewritten by warden 365 on $1" \
        '{selectedText:$s, replacement:$r, rationale:"warden 365 accept-authority fixture"}')" \
    "$SERVER_BASE/wikis/$1/edit-proposals" | jq -r '.id // .proposal.id // .suggestion.id // empty'
}
S_G=$(propose "$W_G"); S_N=$(propose "$W_N")
{ [ -n "$S_G" ] && [ -n "$S_N" ]; } \
  && warden_pass "GA-0c: a pending change is waiting on each wiki ($S_G / $S_N)" \
  || warden_fail "GA-0c: could not raise an edit proposal on one of the fixture wikis (guarded=$S_G unguarded=$S_N)"

# GA-1 (the fix, load-bearing): the workspace admin is NOT the recipient for a
# wiki that has a guardian.
ACC_G=$(as_status "$WA_JAR" POST "$SERVER_BASE/inbox/suggestions/$S_G/accept" '{}')
[ "$ACC_G" = "403" ] \
  && warden_pass "GA-1: a workspace admin accepting a change on a GUARDED wiki is refused (403) — the per-wiki grant is the authority" \
  || warden_fail "GA-1: workspace admin accept on a guarded wiki returned HTTP $ACC_G, expected 403 — the blanket workspace-wide accept right survives"

# GA-2: and nothing was applied behind the refusal.
STATE_G=$(warden_psql_one "SELECT status FROM wiki_suggestions WHERE id='$S_G' LIMIT 1")
BODY_G=$(warden_psql_one "SELECT CASE WHEN content LIKE '%REPLACE-ME-MARKER%' THEN 'untouched' ELSE 'changed' END FROM wikis WHERE lookup_key='$W_G'")
{ [ "$STATE_G" = "pending" ] && [ "$BODY_G" = "untouched" ]; } \
  && warden_pass "GA-2: the guarded wiki's change is still pending and its body untouched" \
  || warden_fail "GA-2: after the refusal the suggestion is '$STATE_G' and the body is '$BODY_G' — the accept partly applied"

# GA-3: the guardian-less wiki still HAS a working accept path — the fallback
# narrowed, it did not disappear (this is what makes GA-1 safe to ship).
ACC_N=$(as_status "$WA_JAR" POST "$SERVER_BASE/inbox/suggestions/$S_N/accept" '{}')
case "$ACC_N" in
  2*) warden_pass "GA-3: the workspace admin CAN still accept on a guardian-less wiki (HTTP $ACC_N) — no review queue is orphaned" ;;
  *)  warden_fail "GA-3: accept on the guardian-less wiki returned HTTP $ACC_N — the fallback was removed instead of narrowed, leaving that queue with no recipient" ;;
esac
BODY_N=$(warden_psql_one "SELECT CASE WHEN content LIKE '%rewritten by warden 365%' THEN 'applied' ELSE 'unapplied' END FROM wikis WHERE lookup_key='$W_N'")
[ "$BODY_N" = "applied" ] \
  && warden_pass "GA-4: the accepted change is actually in the guardian-less wiki's body" \
  || warden_fail "GA-4: the guardian-less wiki's body is '$BODY_N' after a 2xx accept — accepted but not applied"

# GA-5: the guardian's own right is intact on their wiki.
ACC_GUARD=$(as_status "$M_JAR" POST "$SERVER_BASE/inbox/suggestions/$S_G/accept" '{}')
BODY_G2=$(warden_psql_one "SELECT CASE WHEN content LIKE '%rewritten by warden 365%' THEN 'applied' ELSE 'unapplied' END FROM wikis WHERE lookup_key='$W_G'")
{ case "$ACC_GUARD" in 2*) true ;; *) false ;; esac && [ "$BODY_G2" = "applied" ]; } \
  && warden_pass "GA-5: the wiki's guardian accepts the same change and it lands (HTTP $ACC_GUARD)" \
  || warden_fail "GA-5: the guardian's accept returned HTTP $ACC_GUARD and the body is '$BODY_G2' — narrowing the fallback broke the guardian path itself"

Step 5: a SETTLED self-review proposal stays private to its proposer

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
RUN_TS="$(cat $S/ts)"; WS_ID="$(cat $S/ws-id)"
A_JAR="$S/jar-andrew"; WA_JAR="$S/jar-wsadmin"; WA_ID="$(cat $S/wsadmin-id)"

# A guardian-less wiki, so its own reviewer (the workspace admin) is both
# proposer and reviewer — the definition of a self-review.
W_S=$(create_wiki "$A_JAR" "Warden 365 SelfReview $RUN_TS" "$WS_ID")
warden_psql_exec "UPDATE wikis SET content='Warden 365 self-review body $RUN_TS with SELF-MARKER inside.', ownership='company' WHERE lookup_key='$W_S'"
SR=$(as_json "$WA_JAR" -X POST -H 'Content-Type: application/json' \
  -d '{"selectedText":"SELF-MARKER","replacement":"self-reviewed text","rationale":"warden 365 self-review fixture"}' \
  "$SERVER_BASE/wikis/$W_S/edit-proposals" | jq -r '.id // .proposal.id // .suggestion.id // empty')
# self_review is stamped only by the AGENT propose path (propose-edit.ts,
# decision==='queue-for-self'); the HTTP proposal route never sets it. The flag
# is therefore fixture here, exactly like the settled status below — the
# subject under test is post-decision visibility, not how the flag arrives.
warden_psql_exec "UPDATE wiki_suggestions SET self_review=true WHERE id='$SR'"
IS_SELF=$(warden_psql_one "SELECT self_review::text FROM wiki_suggestions WHERE id='$SR' LIMIT 1")
{ [ -n "$SR" ] && [ "$IS_SELF" = "true" ]; } \
  && warden_pass "SR-0: the workspace admin's proposal on a guardian-less wiki is staged as a self-review ($SR)" \
  || warden_fail "SR-0: fixture proposal $SR has self_review='$IS_SELF' — the visibility predicate below would not be testing anything"

# Settle it. The bug is about what happens AFTER a decision, so the decision
# itself is fixture, not the subject.
warden_psql_exec "UPDATE wiki_suggestions SET status='accepted' WHERE id='$SR'"

# SR-1 (the fix): somebody OTHER than the proposer — here the org's super admin,
# who reviews everything reviewable — must not see it in Recently settled.
LEAK=$(as_json "$A_JAR" "$SERVER_BASE/inbox/suggestions?status=settled&workspaceId=$WS_ID" \
  | jq -r --arg id "$SR" '[.suggestions[]? | select(.id == $id)] | length')
[ "${LEAK:-1}" = "0" ] \
  && warden_pass "SR-1: the settled self-review proposal is invisible to a reviewer who is not its proposer" \
  || warden_fail "SR-1: another reviewer sees the settled self-review proposal $SR in /inbox/suggestions?status=settled — hidden while pending, exposed once resolved"

# SR-2: and the proposer still has it, so the fix hid it from others, not from everyone.
MINE=$(as_json "$WA_JAR" "$SERVER_BASE/inbox/my-proposals" \
  | jq -r --arg id "$SR" '[.. | objects | select(.id? == $id)] | length')
[ "${MINE:-0}" != "0" ] \
  && warden_pass "SR-2: the proposer still sees their own settled proposal in /inbox/my-proposals" \
  || warden_fail "SR-2: the proposer can no longer see their own settled proposal — the visibility fix over-reached"

Step 6: AGENT GAPS — Socrates can sync what it wrote, and capture asks where to log

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

# The agent's tool allowlist IS the `audiences` tag on each defineTool (there is
# no second list): server/src/agent/tools.ts skips any def whose audiences omit
# 'agent'. So the outcome "Socrates is offered the tool" is exactly "its
# audiences say agent" — asserted on the two named tools.
MCP=server/src/mcp/server.ts
audience_of() {
  awk -v tool="name: '$1'" '
    index($0, tool) { found = 1 }
    found && /audiences:/ { print; exit }
  ' "$MCP"
}
BF="$(audience_of backfill_wiki)"
RS="$(audience_of regen_status)"

case "$BF" in
  *agent*) warden_pass "AG-1: backfill_wiki — the tool that pulls a wiki body back into the signal graph — is offered to the agent" ;;
  *) warden_fail "AG-1: backfill_wiki is still external-only (audiences: $BF) — Socrates can author a wiki it cannot make searchable or citable" ;;
esac
case "$RS" in
  *agent*) warden_pass "AG-2: regen_status is offered to the agent, so it can confirm the sync job finished" ;;
  *) warden_fail "AG-2: regen_status is still external-only (audiences: $RS) — the agent could queue a sync and never learn whether it landed" ;;
esac

# AG-3: the root-workspace default is gone from the capture surface, and the
# instruction tells the assistant to ask. Both halves matter: removing the
# default without the instruction turns a wrong-workspace capture into a hard
# error, and adding the instruction without removing the default leaves the
# silent fallback in place.
DEFAULTS=$(grep -c "defaults to your root workspace" "$MCP" || true)
[ "${DEFAULTS:-1}" = "0" ] \
  && warden_pass "AG-3a: no tool still advertises a root-workspace default" \
  || warden_fail "AG-3a: $DEFAULTS tool input(s) still say 'defaults to your root workspace' — captures keep landing in root by omission"

if grep -qiE "ask .*(which|what) workspace|which workspace .*(to log|to capture|to use)" "$MCP" server/src/mcp/*.ts; then
  warden_pass "AG-3b: the tool surface instructs the assistant to ask which workspace to log to"
else
  warden_fail "AG-3b: no 'ask which workspace' instruction is present on the MCP tool surface — the assistant has nothing telling it to ask"
fi

# AG-3c: with the default removed, an unspecified write scope must not resolve
# to the root workspace silently.
if grep -qE "is_root|isRoot" server/src/mcp/workspace-ref.ts && grep -qiE "throw|refus|must (name|specify)|ask" server/src/mcp/workspace-ref.ts; then
  warden_pass "AG-3c: workspace-ref no longer resolves an unspecified write scope to root without saying so"
else
  warden_fail "AG-3c: workspace-ref.ts still silently selects the root workspace for an unspecified write scope"
fi

Step 7: ERROR VISIBILITY — a refusal says what was refused

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}"
S=/tmp/warden-365; source "$S/helpers.sh"

# EV-1 (live): the reason IS in the response. An unauthenticated run-start is
# the cheapest deterministic refusal on that exact endpoint.
RESP=$(curl -s -o /tmp/warden-365-err.json -w '%{http_code}' -X POST \
  -H 'Content-Type: application/json' -H "Origin: $SERVER_BASE" \
  -d '{"messages":[{"role":"user","content":"hi"}]}' "$SERVER_BASE/agent/runs")
MSG=$(jq -r 'if type=="object" then (.error // .message // "") | if type=="object" then (.message // "") else . end else "" end' /tmp/warden-365-err.json 2>/dev/null)
{ [ "$RESP" != "200" ] && [ -n "$MSG" ] && [ "$MSG" != "null" ]; } \
  && warden_pass "EV-1: a refused POST /agent/runs answers HTTP $RESP with a readable reason (\"$MSG\") — the server already knows why" \
  || warden_fail "EV-1: POST /agent/runs refused with HTTP $RESP and no readable error field: $(head -c 300 /tmp/warden-365-err.json)"

# EV-2: the client reads that reason before it reads runId. The crash is a
# deref of `res.data.runId` on a response whose `data` is undefined.
HK=app/src/components/socrates/useSocratesRun.ts
if grep -qE "res\.(error|response)" "$HK" && ! grep -qE "const data = res\.data as \{" "$HK"; then
  warden_pass "EV-2: useSocratesRun checks the response's error before dereferencing runId"
else
  warden_fail "EV-2: useSocratesRun still casts res.data straight to {runId,…} — every server refusal renders as 'cannot read properties of undefined'"
fi

# EV-3: email is logged against DELIVERY, not acknowledgement.
EM=server/src/lib/email.ts
if grep -qiE "accepted|queued|delivered|deliveryStatus|bounce" "$EM"; then
  warden_pass "EV-3: email logging distinguishes the provider's acceptance from actual delivery state"
else
  warden_fail "EV-3: server/src/lib/email.ts still writes an unconditional 'email sent' line on the provider's acknowledgement — bounces and suppressions leave no trace"
fi

# EV-4: the sender address is per-deployment, not a hardcoded foreign domain.
if grep -n "RESEND_EMAIL" server/src/bootstrap/env.ts | grep -q "invites@withrobin.ai"; then
  warden_fail "EV-4: RESEND_EMAIL still defaults to the hardcoded 'invites@withrobin.ai' — every deployment depends on one unrelated domain staying verified"
else
  warden_pass "EV-4: RESEND_EMAIL has no hardcoded cross-domain default (required or derived per deployment)"
fi

Step 8: CORRECTNESS — duplicate domains, the refreshing hook, and the client's nullability

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
RUN_TS="$(cat $S/ts)"; WS_ID="$(cat $S/ws-id)"; A_JAR="$S/jar-andrew"

# CO-1 (live): the same domain name twice is rejected, so a panicked retry
# cannot mint the duplicate that has already happened in production.
DNAME="Warden 365 Duplicate $RUN_TS"
DBODY=$(jq -cn --arg n "$DNAME" --arg w "$WS_ID" '{name:$n, workspaceId:$w, description:"warden 365"}')
FIRST=$(as_status "$A_JAR" POST "$SERVER_BASE/domains" "$DBODY")
SECOND=$(as_status "$A_JAR" POST "$SERVER_BASE/domains" "$DBODY")
DUPES=$(warden_psql_one "SELECT count(*) FROM knowledge_domains WHERE workspace_id='$WS_ID' AND name='$DNAME' AND deleted_at IS NULL")
case "$FIRST" in 2*) warden_pass "CO-0: the first create of '$DNAME' succeeded (HTTP $FIRST)" ;; *) warden_fail "CO-0: creating a domain returned HTTP $FIRST" ;; esac
{ [ "$SECOND" = "409" ] || [ "$SECOND" = "400" ]; } \
  && warden_pass "CO-1: creating a second domain with the same name is rejected (HTTP $SECOND)" \
  || warden_fail "CO-1: the duplicate create returned HTTP $SECOND — nothing rejects a duplicate name, so the retry makes a real duplicate"
[ "${DUPES:-2}" = "1" ] \
  && warden_pass "CO-2: exactly one '$DNAME' row exists after both attempts" \
  || warden_fail "CO-2: $DUPES rows named '$DNAME' exist — the duplicate was really created"

# CO-3: the create form goes through the hook that invalidates the domain list,
# so the page it navigates to shows the new domain instead of the stale list.
QC=app/src/components/socrates/QuickCreate.tsx
if grep -q "useCreateDomain" "$QC" && ! grep -q "createDomainMutation" "$QC"; then
  warden_pass "CO-3: QuickCreate creates domains through useCreateDomain (the hook that refreshes the list), not the raw generated mutation"
else
  warden_fail "CO-3: QuickCreate still calls createDomainMutation directly — the domains page it lands on renders the previous, stale list and reads as a failure"
fi

# CO-4: the generated client stops lying about nullability. The document
# declares 3.1.0; the schemas must be emitted for 3.1 (type: [...,'null'])
# rather than 3.0, or every nullable field arrives typed non-nullable.
NULLS=$(grep -c " | null" app/src/lib/generated/types.gen.ts || true)
[ "${NULLS:-0}" -ge 100 ] \
  && warden_pass "CO-4: the generated client types $NULLS nullable unions (was 0 — 173 fields typed non-nullable)" \
  || warden_fail "CO-4: app/src/lib/generated/types.gen.ts contains only ${NULLS:-0} '| null' unions — consumers still get types that lie about what can be null"

if grep -q "target: 'openApi3'" server/scripts/generate-openapi-manifest.ts; then
  warden_fail "CO-5: the manifest still emits schemas with target 'openApi3' (3.0) while server/openapi.json declares $(jq -r '.openapi' server/openapi.json) — that version mismatch is what drops nullability"
else
  warden_pass "CO-5: the manifest emits schemas for the spec version the document declares ($(jq -r '.openapi' server/openapi.json))"
fi

Step 9: ORG ROOT — transfer, the org-admin refusal, the single-holder invariant, and the restore

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}"
S=/tmp/warden-365; source "$S/helpers.sh"
A_JAR="$S/jar-andrew"; OA_JAR="$S/jar-orgadmin"; M_JAR="$S/jar-member"
ANDREW_ID="$(cat $S/andrew-id)"; OA_ID="$(cat $S/orgadmin-id)"; M_ID="$(cat $S/member-id)"
ORG_ID="$(cat $S/org-id)"
A_MEM=$(warden_psql_one "SELECT id FROM member WHERE user_id='$ANDREW_ID' AND organization_id='$ORG_ID'")
OA_MEM=$(warden_psql_one "SELECT id FROM member WHERE user_id='$OA_ID' AND organization_id='$ORG_ID'")

# The transfer action is new in #365, so its exact route is an implementation
# choice. Probe the plausible shapes and use whichever the server answers;
# 404/405 means "not this shape", not "refused".
# try_transfer <jar> <target-member-id> <target-user-id> -> "<status> <method> <url>"
try_transfer() {
  local jar="$1" mem="$2" uid="$3" code last
  local specs=(
    "POST|$SERVER_BASE/org/transfer-super-admin|{\"memberId\":\"$mem\"}"
    "POST|$SERVER_BASE/org/transfer-super-admin|{\"userId\":\"$uid\"}"
    "POST|$SERVER_BASE/org/members/$mem/transfer-super-admin|{}"
    "POST|$SERVER_BASE/org/super-admin/transfer|{\"memberId\":\"$mem\"}"
    "PATCH|$SERVER_BASE/org/members/$mem/role|{\"role\":\"super_admin\"}"
  )
  for spec in "${specs[@]}"; do
    local method="${spec%%|*}" rest="${spec#*|}"
    local url="${rest%%|*}" body="${rest#*|}"
    code=$(as_status "$jar" "$method" "$url" "$body")
    last="$method $url"
    if [ "$code" != "404" ] && [ "$code" != "405" ]; then
      printf '%s %s\n' "$code" "$last"; return 0
    fi
  done
  printf '404 %s\n' "$last"
}

# SA-1 (load-bearing security negative): an ORG ADMIN passes today's
# `manage Member` check, so if transfer isn't restricted to the current holder,
# they can take the root role. They must be refused.
OA_TRY=$(try_transfer "$OA_JAR" "$OA_MEM" "$OA_ID")
OA_CODE=${OA_TRY%% *}
case "$OA_CODE" in
  403) warden_pass "SA-1: an org admin attempting to transfer the super-admin role to themselves is refused (403 on ${OA_TRY#* })" ;;
  404) warden_fail "SA-1: no super-admin transfer endpoint answered at all — the role still cannot be transferred or recovered (#365 organization-ownership)" ;;
  2*)  warden_fail "SA-1: an ORG ADMIN successfully took the super-admin role (HTTP $OA_CODE on ${OA_TRY#* }) — transfer is not restricted to the current holder" ;;
  *)   warden_fail "SA-1: the org admin's transfer attempt returned HTTP $OA_CODE on ${OA_TRY#* }, expected 403" ;;
esac
STILL_ROOT=$(warden_psql_one "SELECT role FROM member WHERE id='$A_MEM'")
[ "$STILL_ROOT" = "super_admin" ] \
  && warden_pass "SA-2: after the refused attempt the original holder is still super admin" \
  || warden_fail "SA-2: the org holder's role is now '$STILL_ROOT' after a refused transfer — the refusal mutated state"

# A plain member is refused too (the weaker negative, cheap to add).
M_TRY=$(try_transfer "$M_JAR" "$OA_MEM" "$OA_ID"); M_CODE=${M_TRY%% *}
{ [ "$M_CODE" = "403" ] || [ "$M_CODE" = "404" ]; } \
  && warden_pass "SA-3: a plain member cannot transfer the root role (HTTP $M_CODE)" \
  || warden_fail "SA-3: a plain member's transfer attempt returned HTTP $M_CODE, expected 403"

# SA-4: the current holder transfers to the org admin.
A_TRY=$(try_transfer "$A_JAR" "$OA_MEM" "$OA_ID"); A_CODE=${A_TRY%% *}
case "$A_CODE" in
  2*) warden_pass "SA-4: the current super admin transfers the role (HTTP $A_CODE on ${A_TRY#* })" ;;
  *)  warden_fail "SA-4: the current holder's transfer returned HTTP $A_CODE on ${A_TRY#* } — there is still no route back for a lost owner account" ;;
esac

NEW_ROLE=$(warden_psql_one "SELECT role FROM member WHERE id='$OA_MEM'")
OLD_ROLE=$(warden_psql_one "SELECT role FROM member WHERE id='$A_MEM'")
COUNT=$(warden_psql_one "SELECT count(*) FROM member WHERE organization_id='$ORG_ID' AND role='super_admin'")
[ "$NEW_ROLE" = "super_admin" ] \
  && warden_pass "SA-5a: the new holder is the org root" \
  || warden_fail "SA-5a: the transfer target's role is '$NEW_ROLE', expected super_admin"
{ [ "$OLD_ROLE" != "super_admin" ] && [ -n "$OLD_ROLE" ]; } \
  && warden_pass "SA-5b: the previous holder was demoted (now '$OLD_ROLE'), not left in place" \
  || warden_fail "SA-5b: the previous holder's role is '$OLD_ROLE' — the old root was not demoted"
[ "${COUNT:-0}" = "1" ] \
  && warden_pass "SA-5c: the org has exactly ONE super admin after the transfer (SQL count)" \
  || warden_fail "SA-5c: the org has $COUNT super admins after the transfer — the swap is not a swap"

# SA-6: the DATABASE refuses a second super admin. The invariant can no longer
# rest on the role being immutable, because it isn't any more.
PROBE=$(psql "$DATABASE_URL" -X -q -v ON_ERROR_STOP=1 -c \
  "INSERT INTO member (id, organization_id, user_id, role, created_at)
   VALUES ('wd365-probe-$(date +%s)', '$ORG_ID', '$M_ID', 'super_admin', now())" 2>&1)
PROBE_RC=$?
if [ "$PROBE_RC" != "0" ] && printf '%s' "$PROBE" | grep -qiE "duplicate key|unique constraint|23505"; then
  warden_pass "SA-6: the database rejects a second super-admin row for the org (unique violation)"
elif [ "$PROBE_RC" != "0" ]; then
  warden_fail "SA-6: the second super-admin insert failed, but not with a unique violation — the guard is something else: $(printf '%s' "$PROBE" | head -2 | tr '\n' ' ')"
else
  warden_fail "SA-6: a second super-admin row INSERTED cleanly — nothing enforces one super admin per org, so a partially applied change still leaves two with no error"
fi
warden_psql_exec "DELETE FROM member WHERE organization_id='$ORG_ID' AND user_id='$M_ID' AND role='super_admin'"
AFTER=$(warden_psql_one "SELECT count(*) FROM member WHERE organization_id='$ORG_ID' AND role='super_admin'")
[ "${AFTER:-0}" = "1" ] \
  && warden_pass "SA-7: the org still holds exactly one super admin after the probe" \
  || warden_fail "SA-7: the org holds $AFTER super admins after the probe — clean up before running anything else"

# SA-8 (RESTORE — read this first if the plan went red above): the recovery
# path, run in the direction that returns the dev database to how it was found.
[ "$(signin_jar "$(cat $S/orgadmin-email)" robin2026 "$OA_JAR")" = "200" ] \
  && warden_pass "SA-8a: the new holder can sign in and act as root" \
  || warden_fail "SA-8a: the new super admin could not sign in — the restore below will not run"
BACK=$(try_transfer "$OA_JAR" "$A_MEM" "$ANDREW_ID"); BACK_CODE=${BACK%% *}
FINAL=$(warden_psql_one "SELECT role FROM member WHERE id='$A_MEM'")
FINAL_COUNT=$(warden_psql_one "SELECT count(*) FROM member WHERE organization_id='$ORG_ID' AND role='super_admin'")
{ [ "$FINAL" = "super_admin" ] && [ "$FINAL_COUNT" = "1" ]; } \
  && warden_pass "SA-8b: the root role was transferred BACK to andrew@robin.ai (HTTP $BACK_CODE) — recovery works and the dev database is as it was found" \
  || warden_fail "SA-8b: RESTORE FAILED — andrew's role is '$FINAL' and the org has $FINAL_COUNT super admin(s). Repair before any other plan runs: UPDATE member SET role='super_admin' WHERE id='$A_MEM'; then demote $OA_MEM."

Shape (a note for the next author)

Why the transfer step is last and self-restoring. Every other identity in this plan (and in plans 10/12/13/14/15/20) depends on andrew@robin.ai being the org root. Transferring the role is therefore the one predicate that cannot run early, and the one that must undo itself. SA-8b is written as a loud, copy-pasteable repair instruction rather than a bare fail, because a red there leaves the shared dev database in a state that reds every later plan for unrelated reasons.

Why the transfer endpoint is probed rather than pinned. The route does not exist on canary — the issue asks for it. Pinning a guessed path would fail the plan for the wrong reason (404 on a route that shipped under a different name). try_transfer treats 404/405 as "not this shape" and only reports SA-1: no transfer endpoint answered when every candidate is absent. Once the implementation lands, collapse the candidate list to the real route.

Why steps 6-8 mix live and source assertions. Four of the thirteen items sit on surfaces warden cannot reach cheaply: the MCP tool list needs a real OAuth bearer (see plan 20's note), the chat client's crash needs a rendered browser run against a browser.sh that is stale for agent-browser 0.26.x, the email delivery state needs a provider webhook, and the generated client is a build artifact. Those are asserted at the seam that carries the behavior — audiences, the error-before-runId read, the emitted | null count — and their real outcome-level coverage lives in the unit-test spec that ships with the implementation. Everything in steps 2-5 and 9 — the load-bearing half of the issue — is a live outcome.

What is deliberately NOT asserted here:

Batch selector

tier: destructive / requires: [needs-server, needs-postgres, needs-model] follows .warden/TIERS.md's primary-tier rule — destructive outranks all three declared requirements, and none of them outranks it. Select with bash .warden/run.sh 22-issue-365.