Warden plan
tier: needs-server requires: [needs-postgres]
The domain-wiki "detach" control (the broken-chain icon that sits next to the row's date in the domain workbench, app/src/components/screens/library/DomainWorkbench.tsx) no longer fires DELETE /domains/:id/wikis/:wikiId off a single stray click. Detaching a wiki now costs strictly more than one click on the visible row — either the trigger is tucked behind an extra affordance (an overflow menu) or it opens a confirmation surface before the mutation runs, or both — and canceling/dismissing that extra step leaves the attachment untouched. The existing canDetach ability gate (ability.can('delete', DomainMembership)) and the row's click-to-navigate behavior (stopPropagation) must survive the change unregressed. Detach's blast radius is bigger than a UI nicety: a domain's wikis feed its citations, which drive domain inheritance and edit grounding downstream — so the guard is a correctness floor, not cosmetic.
$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, psql on PATH; npx agent-browser (0.26.x) for the rendered-browser steps. The env file is provisioned automatically by .warden/run.sh from the tracked .warden/env/ci-env.template.sh plus the machine-local secrets file ~/.config/robin/warden-secrets.sh (mode 600, outside every git repository; override with WARDEN_SECRETS_FILE); first-time setup: mkdir -p ~/.config/robin && cp .warden/env/secrets.local.example.sh ~/.config/robin/warden-secrets.sh && chmod 600 ~/.config/robin/warden-secrets.sh.robin_ci database. Reuses WARDEN_USERS_shell_email/_password (root/super_admin, so it always holds update+delete on Domain/DomainMembership) if already provisioned by a prior plan 10 run in the same suite; otherwise provisions it itself the same way plan 10 does (invitation → sign-up → sign-in → accept → onboard).set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
WB=app/src/components/screens/library/DomainWorkbench.tsx
# DG-1: the row-level detach trigger no longer calls detach.mutate directly
# from its own onClick — that was the one-stray-click bug. Whatever guards it
# (AlertDialog action, overflow-menu item, or a confirm()) must be the thing
# that calls mutate now, not the visible row button itself.
if grep -q "detach.mutate" "$WB"; then
if grep -B8 "detach.mutate" "$WB" | grep -q "AlertDialogAction\|onOpenChange\|onSelect\|window.confirm"; then
warden_pass "detach.mutate is reached through a confirm/menu surface, not a bare row onClick"
else
warden_fail "detach.mutate still appears to fire directly off the row's onClick — no confirm/overflow gate found"
fi
else
warden_fail "detach.mutate call disappeared entirely from DomainWorkbench.tsx — detach may be unreachable"
fi
# DG-2: the permission gate and click-to-navigate isolation both survive.
grep -q "canDetach" "$WB" \
&& warden_pass "canDetach ability gate is still present" \
|| warden_fail "canDetach gate is missing from DomainWorkbench.tsx — detach may now be unguarded"
grep -q "stopPropagation" "$WB" \
&& warden_pass "row click-to-navigate isolation (stopPropagation) is still present" \
|| warden_fail "stopPropagation is missing — opening the detach control may now also navigate the row"
# DG-3: an accessible name for the (possibly relocated) trigger still exists
# somewhere in the file — screen-reader users must still be able to find it.
grep -qi "detach wiki from this domain\|aria-label.*[Dd]etach" "$WB" \
&& warden_pass "an accessible name for the detach affordance is still present" \
|| warden_fail "no accessible name found for the detach affordance — a11y regression"
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
source "$WARDEN_LIB/auth.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
APP_BASE="${APP_URL:-http://localhost:8080}"
SHELL_EMAIL="${WARDEN_USERS_shell_email:-warden-shell@robin.test}"
SHELL_PASSWORD="${WARDEN_USERS_shell_password:-warden-shell-2026}"
# Same self-provisioning rails as plan 10 (invited sign-up under single-user
# mode); a no-op if plan 10 already ran this suite.
SHELL_MEMBER=$(warden_psql_one "SELECT count(*) FROM member m JOIN users u ON u.id = m.user_id WHERE u.email = '$SHELL_EMAIL'")
USERS_TOTAL=$(warden_psql_one "SELECT count(*) FROM users")
if [ "${SHELL_MEMBER:-0}" = "0" ] && [ "${USERS_TOTAL:-0}" != "0" ]; then
warden_psql_exec "INSERT INTO invitation (id, organization_id, email, role, status, inviter_id, expires_at, created_at)
SELECT 'warden-dg-' || floor(extract(epoch from now()))::bigint, o.id, '$SHELL_EMAIL', 'super_admin', 'pending', m.user_id, now() + interval '1 day', now()
FROM organization o JOIN member m ON m.organization_id = o.id LIMIT 1"
fi
JAR="$(mktemp /tmp/warden-detachguard-cookies-XXXXXX.txt)"
curl -s -o /dev/null -c "$JAR" -X POST -H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
-d "$(jq -cn --arg e "$SHELL_EMAIL" --arg p "$SHELL_PASSWORD" '{email:$e,password:$p}')" \
"$APP_BASE/api/auth/sign-in/email" >/dev/null
export WARDEN_AUTH_STRATEGY=cookie-session
export WARDEN_AUTH_COOKIE_JAR="$JAR"
WS_ID=$(warden_authed_curl "$APP_BASE/api/workspaces" | jq -r '.workspaces[0].id')
[ -n "$WS_ID" ] && [ "$WS_ID" != "null" ] \
&& warden_pass "resolved a workspace to fixture against ($WS_ID)" \
|| warden_fail "could not resolve any workspace for the warden identity"
# Workspace-scoped domain reads require a workspace_members row; org
# super_admin alone does not grant it. Same self-provisioning spirit as the
# invitation insert above — idempotent on the (workspace, user) unique index.
warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role)
SELECT 'warden-dg-wm-' || floor(extract(epoch from now()))::bigint, '$WS_ID', u.id, 'workspace_admin'
FROM users u WHERE u.email = '$SHELL_EMAIL'
ON CONFLICT (workspace_id, user_id) DO NOTHING"
DOMAIN_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg n "Warden Detach Guard $(date +%s)" --arg w "$WS_ID" '{name:$n, workspaceId:$w}')" \
"$APP_BASE/api/domains" | jq -r '.id')
WIKI_ID=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg n "Warden Detach Guard Wiki" --arg w "$WS_ID" '{name:$n, workspaceId:$w, scope:{kind:"workspace"}}')" \
"$APP_BASE/api/wikis" | jq -r '.id')
[ -n "$DOMAIN_ID" ] && [ "$DOMAIN_ID" != "null" ] && [ -n "$WIKI_ID" ] && [ "$WIKI_ID" != "null" ] \
&& warden_pass "fixture domain ($DOMAIN_ID) and wiki ($WIKI_ID) created" \
|| warden_fail "could not create the fixture domain/wiki over the API"
ATTACH_CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg w "$WIKI_ID" '{wikiId:$w}')" \
"$APP_BASE/api/domains/$DOMAIN_ID/wikis")
[ "$ATTACH_CODE" = "201" ] \
&& warden_pass "fixture wiki attached to fixture domain (201)" \
|| warden_fail "attaching the fixture wiki returned $ATTACH_CODE, expected 201"
echo "$DOMAIN_ID" > /tmp/warden-detachguard-domain-id
echo "$WIKI_ID" > /tmp/warden-detachguard-wiki-id
echo "$WS_ID" > /tmp/warden-detachguard-ws-id
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
DOMAIN_ID="$(cat /tmp/warden-detachguard-domain-id 2>/dev/null)"
WIKI_ID="$(cat /tmp/warden-detachguard-wiki-id 2>/dev/null)"
WS_ID="$(cat /tmp/warden-detachguard-ws-id 2>/dev/null)"
# DomainWorkbench mounts at /workspaces/[workspaceId]/domains/[domainId]
# (app/src/app/(shell)/workspaces/...), not /domains/[id].
DOMAIN_URL="$APP_BASE/workspaces/$WS_ID/domains/$DOMAIN_ID"
if [ -z "$DOMAIN_ID" ] || [ -z "$WIKI_ID" ] || [ -z "$WS_ID" ]; then
warden_skip "rendered detach-guard checks" "step 2 did not produce a fixture domain/wiki"
elif ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered detach-guard checks" "npx agent-browser is unavailable on this box"
else
wb_count() { npx agent-browser get count "$1" 2>/dev/null | tr -dc '0-9'; }
# Land on the domain workbench, wikis tab, authed via the cookie jar from
# step 2 (agent-browser reuses the OS cookie store set by prior curl calls
# only if it shares a browser profile with them — so this step logs in
# fresh through the real form using the same warden identity).
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']" "${WARDEN_USERS_shell_email:-warden-shell@robin.test}" >/dev/null
npx agent-browser fill "input[type='password'], input[name='password']" "${WARDEN_USERS_shell_password:-warden-shell-2026}" >/dev/null
npx agent-browser click "button[type='submit']" >/dev/null
sleep 2
npx agent-browser open "$DOMAIN_URL" >/dev/null
npx agent-browser wait ".dgrp__row" >/dev/null 2>&1
# DG-4: a single click on the row's own danger-styled control must NOT
# leave the wiki detached — either that control isn't there (tucked behind
# an overflow item) or clicking it opens a confirmation surface instead of
# firing the request.
BEFORE=$(wb_count ".dgrp__row")
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0" ]; then
warden_fail "no .dgrp__row rendered on $DOMAIN_URL — the rendered check would be vacuous"
fi
npx agent-browser click ".gact--danger" >/dev/null 2>&1
sleep 1
if [ "$(wb_count ".dgrp__row")" = "$BEFORE" ]; then
warden_pass "clicking the row's detach-adjacent control did not remove the row on its own (a gate intervened)"
else
warden_fail "the row disappeared after ONE click on the row's detach control — no confirm/overflow gate is actually blocking it"
fi
# DG-5: a visible confirmation surface (dialog or menu) is now open.
CONFIRM_VISIBLE=0
for sel in "[role='alertdialog']" "[role='dialog']" "[role='menu']"; do
[ "$(wb_count "$sel")" != "0" ] && CONFIRM_VISIBLE=1 && break
done
if [ "$CONFIRM_VISIBLE" = "1" ]; then
warden_pass "a confirmation/menu surface is visible after the first click"
# DG-6: dismissing it (Escape) leaves the wiki attached.
npx agent-browser key Escape >/dev/null 2>&1
sleep 1
npx agent-browser open "$DOMAIN_URL" >/dev/null
npx agent-browser wait ".dgrp__row" >/dev/null 2>&1
AFTER_ESCAPE=$(wb_count ".dgrp__row")
[ "$AFTER_ESCAPE" = "$BEFORE" ] \
&& warden_pass "dismissing the confirmation left the wiki attached (row count unchanged: $AFTER_ESCAPE)" \
|| warden_fail "row count changed after dismissing the confirmation ($BEFORE -> $AFTER_ESCAPE) — dismiss should be a no-op"
else
warden_skip "confirm-surface visibility check" "no dialog/menu role appeared — implementation may gate via a different pattern; re-read the shipped markup before failing this"
fi
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 — run this plan via .warden/run.sh}"
DOMAIN_ID="$(cat /tmp/warden-detachguard-domain-id 2>/dev/null)"
WIKI_ID="$(cat /tmp/warden-detachguard-wiki-id 2>/dev/null)"
if [ -z "$DOMAIN_ID" ] || [ -z "$WIKI_ID" ]; then
warden_skip "negative-path detach authz" "step 2 did not produce a fixture domain/wiki"
else
# The API-layer gate is the real backstop regardless of what the UI shows:
# DELETE without an authed, sufficiently-privileged session must not detach.
UNAUTH_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
"${APP_URL:-http://localhost:8080}/api/domains/$DOMAIN_ID/wikis/$WIKI_ID")
case "$UNAUTH_CODE" in
401|403|404) warden_pass "unauthenticated DELETE on the detach route is refused ($UNAUTH_CODE)" ;;
*) warden_fail "unauthenticated DELETE on the detach route returned $UNAUTH_CODE — expected 401/403/404" ;;
esac
fi
DG-1 is deliberately a shape check, not a pixel check: it accepts an AlertDialog, a dropdown-menu item, or a plain window.confirm as the gate — whichever the implementation picks, as long as detach.mutate is reached through it rather than the row's raw onClick. Step 3's DG-5/DG-6 skip (never fail) when no role="alertdialog"|"dialog"|"menu" appears, because a window.confirm() gate renders no DOM the browser driver can select — that path is still covered by DG-4 (the row survives one click) and by the unit tests (below), just not by a DOM-visible confirm assertion here.
Stays manual / not asserted here:
Filename carries the v11-batch prefix segment so bash .warden/run.sh v11-batch (a filename-prefix match, per run.sh's non-phase:/tier: argument resolution) selects this plan and any siblings from the same batch, without adding an eighth value to the closed tier: vocabulary in .warden/TIERS.md (check-plan-tiers.mjs rejects anything outside the seven canonical labels). tier: needs-server / requires: [needs-postgres] is this plan's real, checker-honored declaration.