Warden plan
tier: needs-server requires: [needs-postgres]
The org-admin control plane (/admin) is organized into exactly the three groups issue #214 defines, with the moves/renames/dissolutions it calls for actually shipped — not just described:
/admin — guardian appointment lives on the workspace-admin side, and a person's guardianships surface only in-context (on their member view and inside each workspace)./admin (org authority only), the existing Members/Workspaces mutations, and the export-data flow all keep working exactly as before.This is a structure/IA proof, not a visual-design proof (the issue is explicit: "Structure only — not visual design").
$APP_URL (default http://localhost:8080) proxying to the Hono server on $SERVER_URL (default http://localhost:3000), backed by postgres with vector.curl, jq, grep on PATH; npx agent-browser (0.26.x) for the rendered steps. Env file provisioned by .warden/run.sh per the usual ci-env.template.sh + ~/.config/robin/warden-secrets.sh convention.andrew@robin.ai / robin2026 (via bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh), which the plan assumes holds org_admin or super_admin.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
IDX="app/src/app/(shell)/admin/page.tsx"
# IA-1: exactly three group labels, matching #214's vocabulary.
for label in "People & access" "Content" "Organization"; do
grep -qi "$label" "$IDX" \
&& warden_pass "admin index declares the '$label' group" \
|| warden_fail "admin index is missing the '$label' group — #214's three-group IA is not in place"
done
# IA-2: no standalone Guardians row under /admin (org-admin index).
if grep -qi '"Guardians"' "$IDX"; then
warden_fail "admin index still lists a standalone Guardians row — guardian appointment belongs to the workspace-admin view per #214, not org-admin"
else
warden_pass "no standalone Guardians row on the admin index"
fi
# IA-3: Spend does not appear in the live group definitions (commented out or removed, not just hidden by CSS).
if grep -E '^\s*\{' "$IDX" | grep -qi "spend"; then
warden_fail "a live 'Spend' row exists in the admin index rows array — #214 hides Spend from the nav"
else
warden_pass "Spend is absent from the live admin index rows"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
IDX="app/src/app/(shell)/admin/page.tsx"
# IA-4: Content style renamed to House style, everywhere it's user-facing.
if grep -qi "house style" "$IDX"; then
warden_pass "admin index shows 'House style', not 'Content style'"
else
warden_fail "admin index still shows the old 'Content style' label — #214 renames it to 'House style'"
fi
CONTENT_PAGE=$(grep -rli "house style" app/src/app 2>/dev/null | head -1)
if [ -n "$CONTENT_PAGE" ]; then
warden_pass "some admin page renders the 'House style' label ($CONTENT_PAGE)"
else
warden_fail "no admin page renders a 'House style' label — the rename looks index-only, not applied to the page itself"
fi
# IA-5: Providers is no longer its own admin-index destination — it folded into Settings as Models.
if grep -qi '"Providers' "$IDX"; then
warden_fail "admin index still lists a standalone 'Providers' destination — #214 folds it into Settings as Models"
else
warden_pass "no standalone Providers row on the admin index"
fi
if grep -rqi "models" "app/src/app/(shell)/admin/settings/page.tsx"; then
warden_pass "the Settings page itself references Models"
else
warden_fail "the Settings page has no Models section — Providers→Models move is not wired into Settings"
fi
# IA-6: a Connections destination exists in the Organization group, describing the org MCP endpoint/keypair (not just the personal Settings > Connections page).
if grep -qi "connections" "$IDX"; then
warden_pass "admin index lists a Connections destination"
else
warden_fail "admin index has no Connections destination — MCP endpoint/keypair have no Organization-group home"
fi
# IA-7: a Data & danger destination/section exists, and export-all is reachable from it.
if grep -qi "data.*danger\|danger.*data" "$IDX"; then
warden_pass "admin index (or a linked page) names a 'Data & danger' surface"
else
warden_fail "no 'Data & danger' surface found on the admin index — export/delete have no fenced home"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
JAR="$(mktemp /tmp/warden-admin214-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"
# IA-8: an unauthenticated caller is still refused the members list.
UNAUTH_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$APP_BASE/api/org/members")
case "$UNAUTH_CODE" in
401|403) warden_pass "unauthenticated GET /api/org/members is refused ($UNAUTH_CODE)" ;;
*) warden_fail "unauthenticated GET /api/org/members returned $UNAUTH_CODE — org-member data may be exposed without auth" ;;
esac
# IA-9: the seeded org-admin can still list org members (existing surface, unregressed by the IA move).
MEMBERS_CODE=$(curl -s -b "$JAR" -H "Origin: $APP_BASE" -o /tmp/warden-admin214-members.json -w '%{http_code}' "$APP_BASE/api/org/members")
[ "$MEMBERS_CODE" = "200" ] \
&& warden_pass "authenticated org-admin GET /api/org/members returns 200" \
|| warden_fail "authenticated org-admin GET /api/org/members returned $MEMBERS_CODE, expected 200"
MEMBER_COUNT=$(jq -r '.members | length' /tmp/warden-admin214-members.json 2>/dev/null)
[ -n "$MEMBER_COUNT" ] && [ "$MEMBER_COUNT" != "0" ] && [ "$MEMBER_COUNT" != "null" ] \
&& warden_pass "at least one member returned ($MEMBER_COUNT) — enough fixture data to open a member view" \
|| warden_fail "org-members response has no usable members — cannot exercise the member-page checks in step 4"
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered member-page checks" "npx agent-browser is unavailable on this box"
else
npx agent-browser cookies clear >/dev/null 2>&1
npx agent-browser open "$APP_BASE/login" >/dev/null
npx agent-browser wait "input" >/dev/null
npx agent-browser fill "input[type='email'], input[name='email']" "andrew@robin.ai" >/dev/null
npx agent-browser fill "input[type='password'], input[name='password']" "robin2026" >/dev/null
npx agent-browser click "button[type='submit']" >/dev/null
sleep 2
npx agent-browser open "$APP_BASE/admin/members" >/dev/null
npx agent-browser wait "text=Members" >/dev/null 2>&1
npx agent-browser click "table tr, [data-member-row], .mrow" >/dev/null 2>&1
sleep 1
# IA-10: opening a member surfaces an Organization block (role, MCP access,
# lifecycle) on the same screen — the "member page" the issue describes,
# whether that's a route or a same-page detail panel.
ORG_BLOCK=$(npx agent-browser get text "text=/Org role|MCP access/i" 2>/dev/null)
if [ -n "$ORG_BLOCK" ]; then
warden_pass "an Organization block (org role / MCP access) is visible from the member view"
else
warden_skip "member-page Organization block check" "selector drift or no member row was opened — re-verify manually before failing this"
fi
# IA-11: a Workspaces block lists the person's workspace memberships + roles.
WS_BLOCK=$(npx agent-browser get text "text=/Workspaces? \(/i" 2>/dev/null)
if [ -n "$WS_BLOCK" ]; then
warden_pass "a Workspaces block (with a count) is visible from the member view: $WS_BLOCK"
else
warden_fail "no 'Workspaces (N)' block found on the member view — the issue calls for editable per-person workspace access here, not only on the Workspaces page"
fi
# IA-12: a Guardian-of block is present and is presented as read-only context
# (no role selector / no remove control next to it), distinct from the
# editable Workspaces block above.
GUARDIAN_BLOCK=$(npx agent-browser get text "text=/Guardian of/i" 2>/dev/null)
if [ -n "$GUARDIAN_BLOCK" ]; then
warden_pass "a 'Guardian of' block is visible from the member view"
else
warden_fail "no 'Guardian of' block found on the member view — guardianships must show in-context per #214, not be omitted entirely"
fi
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# IA-13: no client-side call to a destructive, unscoped "delete everything"
# route exists unless a matching org-scoped server route does. This guards
# against the exact defect #214's own history describes: a delete button
# wired to nothing (or worse, to an unscoped table truncation).
DANGER_HITS=$(grep -rli "delete all data\|delete everything\|DELETE /users/data\|DELETE /org/data" app/src 2>/dev/null)
if [ -z "$DANGER_HITS" ]; then
warden_pass "no client code references a delete-all-data control — Data & danger's destructive action is not shipped ahead of its endpoint"
else
# If it exists client-side, a real org-scoped server route must exist too.
if grep -rqi "delete.*org.*data\|org.*delete.*data" server/src/modules 2>/dev/null; then
warden_pass "a delete-all UI reference exists AND a matching org-scoped server route exists"
else
warden_fail "client code references a delete-all-data control but no org-scoped server route backs it: $DANGER_HITS"
fi
fi
# IA-14: whatever export-all-data control exists still requires org authority
# server-side (unauthenticated export must be refused).
UNAUTH_EXPORT=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${SERVER_URL:-http://localhost:3000}/users/export?format=zip")
case "$UNAUTH_EXPORT" in
401|403) warden_pass "unauthenticated data-export request is refused ($UNAUTH_EXPORT)" ;;
*) warden_fail "unauthenticated data-export request returned $UNAUTH_EXPORT — expected 401/403" ;;
esac
Step 1/2's greps are loose source checks — they read admin/page.tsx's row labels, not rendered pixels, so a page that renames a label without moving the underlying control will still pass here; step 3's live calls and step 4's rendered pass are the load-bearing proof that the surfaces actually work end to end. IA-10/IA-11/IA-12 deliberately don't assert a specific route shape (/admin/members/:id vs. a same-page detail panel) — the issue asks for "a member page," not a URL scheme, and the codebase already renders per-member detail as a same-page panel (see admin/members/page.tsx), which satisfies the intent if the missing Workspaces/Guardian-of blocks land on it.
Stays manual / not asserted here:
/settings/connections page — see the open question in the design notes; IA-6 only asserts a Connections destination exists in the Organization group, not which of the two shapes it takes.Filename carries the v11-batch prefix segment so bash .warden/run.sh v11-batch (a filename-prefix match per run.sh's non-phase:/tier: argument resolution) selects this plan alongside any siblings from the same design sweep, without adding an eighth value to the closed tier: vocabulary in .warden/TIERS.md. tier: needs-server / requires: [needs-postgres] is this plan's real, checker-honored declaration.