Warden plan
tier: destructive requires: [needs-server, needs-postgres, needs-model]
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.
DELETE.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.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.tier: destructiveThe 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.
:3000 with a real OpenRouter key — bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh.dev-server.sh points at (:5433, robin_ci) with vector.bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh (idempotent) — andrew@robin.ai / robin2026, super_admin, onboarded.curl, jq, psql on PATH. .warden/run.sh provisions the env file.robin2026. They are per-run (RUN_TS in the local part) and are left behind deliberately — cleaning them up would race the audit rows they wrote.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
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"
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"
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"
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"
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
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
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
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."
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:
backfill_wiki after authoring. AG-1/2 prove the tool is offered; whether the skill prompt makes it habitual is a model-behavior question for a needs-model eval, not a permission gate.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.