Warden plan
tier: destructive requires: [needs-server, needs-postgres]
Acceptance sentence. The root workspace shows up in your workspace menus only if you are actually one of its members — and if you are not, naming it tells you exactly what naming a workspace that does not exist tells you.
Issue #391 is one ruling: for V1, root is members-only. It disappears from every workspace-listing and picker surface for callers who hold no workspace_members row in it, and it keeps working normally for the people who do.
The naive reading of that ruling is "find the place that adds root and delete it". That reading fixes one surface out of two, because root leaks by two independent mechanisms that look nothing alike:
list_workspaces (server/src/mcp/server.ts:828-840) unions the org's is_root workspace onto callerWorkspaceIds whenever the caller's org role is org_admin or super_admin — no membership row consulted. That convenience is what the issue explicitly retires.GET /workspaces (server/src/modules/workspaces/routes.ts:101-114) filters with accessibleByDrizzle(ability, 'read', 'Workspace'), and the CASL rule for a plain member (packages/permissions/src/ability.ts:428-431) is can('read','Workspace',{ organizationId, visibility:'open' }). The root workspace is provisioned visibility:'open' (server/src/modules/iam/enterprise.ts:62-63, schema default server/src/db/schema.ts:1374). So root reaches every plain member of the org through the open-visibility arm, with no is_root in sight. The only root-aware rule in the whole ability file is cannot('delete','Workspace',{ isRoot: true }) (ability.ts:468).A fix that only removes (1) leaves the app's switcher, /workspaces, /admin/workspaces and the member-assignment picker still showing root to everyone, because they all read GET /workspaces through the one generated client function listWorkspaces (app/src/hooks/useWorkspaces.ts). A fix that only flips root's visibility to private closes (2) and the plain-member half of (1), but leaves root visible to every org_admin — whose manage all in org rule (ability.ts:242) does not care about visibility. The org-admin predicate below is what forces an explicit root carve-out rather than a visibility flip.
Every predicate here is asserted against the live app on :3000 — real HTTP, real MCP transport (POST /mcp?token=…, JSON-RPC tools/call), real Postgres — never against the diff.
GET /workspaces (the switcher/picker feed), not in MCP list_workspaces. (HL-1, ML-1, ML-2)org_admin who holds no root membership row also cannot see it. This is the behaviour change — the always-surface-root-for-org-roles convenience is retired, on both transports. (HL-4, ML-4 — load-bearing)GET /workspaces/:id and its content comes back from search. (HL-5…HL-8, ML-5, ML-6, SR-2)ability.ts:428-431 outright, which hides every open workspace from everyone who is not a member — a much larger behaviour change than #391 asked for, and one no other predicate would notice. (HL-2, HL-9 — the shape guard)workspace ref, GET /workspaces/:id, and the x-workspace-id header — each run twice, once naming root and once naming a workspace that was never created, and the two answers compared after normalising the ref out. If they differ in status or in wording, the difference is the oracle, and hiding root from the menu accomplished nothing. (XM-1…XM-6)SR-1)tier: destructiveThe plan provisions two workspaces, five fixture identities and two marker rows in Andrew's live dev database — the same database plans 10/12/13/14/15/20/22 /23 read. No DROP SCHEMA / pushTestSchema(); this runs through the real API in plan 20/22/23's style. Fixture rows carry RUN_TS in every name and are left behind deliberately (deleting them would race the audit rows they wrote); step 7 prints the one-line cleanup.
needs-model is deliberately absent: the single search call passes mode=bm25, which short-circuits the embedder on both transports.
:3000 — bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh. No OpenRouter key required.dev-server.sh points at (:5433, robin_ci) with vector. KEY_ENCRYPTION_SECRET must be in the provisioned warden env file — step 1 mints MCP tokens with it and fails loudly if it is missing.bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh (idempotent) — andrew@robin.ai / robin2026, super_admin, onboarded.curl, jq, psql on PATH, and server/node_modules/.bin/tsx. rg is not assumed to exist on this box — every text match is grep.$APP_URL and npx agent-browser; it skips rather than fails when either is missing. Every load-bearing claim is already made at the API level.warden_psql_one pipes through tr -d '[:space:]' — scalars only. Every assertion below that touches human text (a refusal message, a workspace name) reads it out of the HTTP/JSON-RPC response with jq, never out of psql.rg may be absent: grep -q throughout.robin2026 (plan 22's mk_user). Signing in does not provision a keypair — only /sign-up/email enqueues that job (modules/iam/auth.ts), and users.public_key defaults to '' NOT NULL so IS NOT NULL probes lie. The mint script in step 1 provisions the keypair itself via the product's generateKeypair before signing, exactly as the provision worker does (plan 23's pattern). The HTTP sign-in is still required: it proves the credential row and yields the cookie jars steps 2/4/5 use.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-391
mkdir -p "$S"
echo "$SERVER_BASE" > "$S/base"; echo "$RUN_TS" > "$S/ts"
# One lowercase alphanumeric token, so to_tsquery('english') sees exactly one
# lexeme and stemming cannot touch it.
MARK="zqwarden391${RUN_TS}"
echo "$MARK" > "$S/mark"
export WARDEN_AUTH_STRATEGY=cookie-session
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"
cat > "$S/helpers.sh" <<'HELP'
S=/tmp/warden-391
SERVER_BASE="$(cat $S/base)"
MARK="$(cat $S/mark)"
RUN_TS="$(cat $S/ts)"
export WARDEN_AUTH_STRATEGY=cookie-session
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"
}
# HTTP read/write scope travels in x-workspace-id (core/authz/context.ts).
# ws_json <jar> <workspace-id|-> <curl-args...>
ws_json() {
local jar="$1" ws="$2"; shift 2
if [ "$ws" = "-" ]; then
curl -s -b "$jar" -H "Origin: $SERVER_BASE" "$@"
else
curl -s -b "$jar" -H "Origin: $SERVER_BASE" -H "x-workspace-id: $ws" "$@"
fi
}
# mcp_call <token> <tool> <json-args> -> the JSON-RPC result frame, un-wrapped
# from SSE. The transport is STATELESS (a fresh transport per request,
# routes/mcp.ts) so tools/call needs no `initialize` handshake. Accept MUST
# carry both media types or the SDK 406s. No Origin header: a MISSING Origin is
# legal for non-browser clients, a WRONG one is a 403.
mcp_call() {
local tok="$1" tool="$2" args="$3"
local frame body
frame=$(curl -s -X POST \
-H 'accept: application/json, text/event-stream' \
-H 'content-type: application/json' \
-d "$(jq -cn --arg n "$tool" --argjson a "$args" \
'{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:$n,arguments:$a}}')" \
"$SERVER_BASE/mcp?token=$tok")
body=$(printf '%s' "$frame" | grep '^data: ' | tail -1 | sed 's/^data: //')
[ -z "$body" ] && body="$frame"
printf '%s' "$body"
}
mcp_text() { mcp_call "$1" "$2" "$3" | jq -r '.result.content[0].text // empty'; }
# ws_ids <payload> -> one workspace id per line, tolerant of the envelope.
# GET /workspaces returns a bare array today; a later envelope must not turn
# every negative below into a vacuous pass, so the unwrap is explicit and
# `parses` below is what stops "absent" from being read as proof of hiding.
ws_ids() {
printf '%s' "$1" | jq -r '
(if type=="array" then . elif type=="object" then (.workspaces? // .items? // .data? // []) else [] end)
| .[]? | (.id // .workspaceId // empty)' 2>/dev/null
}
# listed <payload> <id> -> yes/no. An empty payload or an empty id is ALWAYS
# "no" for a positive claim.
listed() {
{ [ -z "$1" ] || [ -z "$2" ]; } && { echo no; return; }
ws_ids "$1" | grep -qx "$2" && echo yes || echo no
}
# parses <payload> -> yes/no: did we get a list-shaped payload with >0 entries?
parses() {
local n; n=$(ws_ids "$1" | grep -c . )
[ "${n:-0}" -gt 0 ] && echo yes || echo no
}
HELP
# shellcheck disable=SC1090
source "$S/helpers.sh"
JAR_A="$S/jar-andrew"
[ "$(signin_jar andrew@robin.ai robin2026 "$JAR_A")" = "200" ] \
&& warden_pass "WS-0: signed in as andrew@robin.ai (super admin) against $SERVER_BASE" \
|| warden_fail "WS-0: 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")
ROOT_ID=$(warden_psql_one "SELECT id FROM workspaces WHERE organization_id='$ORG_ID' AND is_root = true LIMIT 1")
ROOT_SLUG=$(warden_psql_one "SELECT slug FROM workspaces WHERE id='$ROOT_ID'")
{ [ -n "$ANDREW_ID" ] && [ -n "$ORG_ID" ] && [ -n "$ROOT_ID" ]; } \
&& warden_pass "WS-0b: resolved org ($ORG_ID) and its root workspace ($ROOT_ID, slug $ROOT_SLUG)" \
|| warden_fail "WS-0b: could not resolve andrew's user/org/root chain — every predicate below depends on it"
for v in ANDREW_ID ORG_ID ROOT_ID ROOT_SLUG; do printf '%s\n' "${!v}" > "$S/$v"; done
# WS-0c: andrew must be a REAL root member. The whole members-only reading rests
# on him: if he holds no workspace_members row in root, then HL-7/HL-8 would be
# green off the super-admin blanket rule instead of off membership, and the
# regression guard would be proving nothing. This is a PRECONDITION on the
# environment, not a claim about the fix — if it is red, seed the row before
# reading anything else in this run.
A_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ANDREW_ID' AND workspace_id='$ROOT_ID'")
[ "$A_ROOT" = "1" ] \
&& warden_pass "WS-0c: andrew holds a real workspace_members row in root — the members-only regression guard is anchored on membership, not on his super_admin role" \
|| warden_fail "WS-0c: andrew holds $A_ROOT root membership rows. HL-7/HL-8 would then be testing the super_admin blanket rule, not #391's members-only reading. Seed the row (INSERT INTO workspace_members …) or record on #391 that the owner is expected to reach root without one"
# WS-0d: root's visibility, recorded not asserted. Flipping it to 'private' is
# one admissible half of a fix (see Shape); this line is here so a triager
# reading a later red knows which mechanism was in play during the run.
ROOT_VIS=$(warden_psql_one "SELECT visibility FROM workspaces WHERE id='$ROOT_ID'")
warden_pass "WS-0d: root workspace visibility is '$ROOT_VIS' at run time (informational — 'open' means the CASL open arm is live, 'private' means a visibility flip was part of the fix)"
# --- two fixture workspaces -------------------------------------------------
# alpha : PRIVATE, the plain member's own — so their listing has something in it
# and an empty response cannot masquerade as "root is hidden".
# open : OPEN, non-root, NOBODY is ever granted a membership — the shape guard
# (HL-2/HL-9). If a fix hides this too, it deleted the open arm instead
# of carving root out of it.
mk_ws() {
local slot="$1" vis="$2"
local wid="wd391${slot}$(printf '%s' "$RUN_TS" | tail -c 6)$(head -c 5 /dev/urandom | od -An -tx1 | tr -d ' \n')"
warden_psql_exec "INSERT INTO workspaces (id, organization_id, name, slug, description, visibility, is_root)
VALUES ('$wid', '$ORG_ID', 'Warden 391 $slot $RUN_TS', 'warden391-$slot-$RUN_TS',
'warden 391 fixture', '$vis', false)"
printf '%s\n' "$wid" > "$S/ws-$slot"
printf '%s\n' "warden391-$slot-$RUN_TS" > "$S/slug-$slot"
}
mk_ws alpha private
mk_ws open open
WS_A="$(cat $S/ws-alpha)"; WS_O="$(cat $S/ws-open)"
FIX_OK=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE id IN ('$WS_A','$WS_O') AND is_root=false")
OPEN_OK=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE id='$WS_O' AND visibility='open'")
{ [ "$FIX_OK" = "2" ] && [ "$OPEN_OK" = "1" ]; } \
&& warden_pass "WS-1: two non-root fixture workspaces exist — one private, one open" \
|| warden_fail "WS-1: fixture workspaces did not provision as designed (nonroot=$FIX_OK open=$OPEN_OK) — the shape guard HL-2/HL-9 would be vacuous"
# --- five fixture identities -------------------------------------------------
# dual — plain member; workspace_members in alpha only (nothing in root).
# rooter — plain member; workspace_members in ROOT only. The members-only
# regression guard that CANNOT be satisfied by any role rule.
# orphan — plain org member; ZERO workspace_members rows anywhere.
# oadmin — org_admin; member of alpha; NO root row. The behaviour change.
# oadminrt — org_admin; member of alpha AND root. The over-correction guard:
# hiding root from admins-without-a-row must not hide it from
# admins WITH one.
mk_user() {
local slot="$1" orole="$2"; shift 2 # remaining args: workspace ids to join
local email="warden391-${slot}-${RUN_TS}@robin.test"
local uid="wd391${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 391 $slot', true, true, now())"
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())"
local i=0
for ws in "$@"; do
i=$((i+1))
warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role)
VALUES ('$uid-wsm$i', '$ws', '$uid', 'member')
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role='member'"
done
printf '%s\n' "$uid" > "$S/$slot-id"; printf '%s\n' "$email" > "$S/$slot-email"
local code; code=$(signin_jar "$email" robin2026 "$S/jar-$slot")
[ "$code" = "200" ] \
&& warden_pass "WS-2($slot): identity provisioned and signed in (org=$orole, workspaces=$#)" \
|| warden_fail "WS-2($slot): fixture identity could not sign in (HTTP $code) — every predicate using it is unreliable"
}
mk_user dual member "$WS_A"
mk_user rooter member "$ROOT_ID"
mk_user orphan member
mk_user oadmin org_admin "$WS_A"
mk_user oadminrt org_admin "$WS_A" "$ROOT_ID"
# WS-3: the membership matrix is the plan's whole premise — assert it, do not
# assume the INSERTs above landed the way they read.
DUAL_ID="$(cat $S/dual-id)"; ROOTER_ID="$(cat $S/rooter-id)"
ORPHAN_ID="$(cat $S/orphan-id)"; OADMIN_ID="$(cat $S/oadmin-id)"
OADMRT_ID="$(cat $S/oadminrt-id)"
D_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$DUAL_ID' AND workspace_id='$ROOT_ID'")
R_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ROOTER_ID' AND workspace_id='$ROOT_ID'")
R_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ROOTER_ID'")
O_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ORPHAN_ID'")
OA_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$OADMIN_ID' AND workspace_id='$ROOT_ID'")
OART_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$OADMRT_ID' AND workspace_id='$ROOT_ID'")
OPEN_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE workspace_id='$WS_O'")
{ [ "$D_ROOT" = "0" ] && [ "$R_ROOT" = "1" ] && [ "$R_N" = "1" ] && [ "$O_N" = "0" ] \
&& [ "$OA_ROOT" = "0" ] && [ "$OART_ROOT" = "1" ] && [ "$OPEN_N" = "0" ]; } \
&& warden_pass "WS-3: membership matrix holds — dual/oadmin hold NO root row, rooter holds root and only root, oadminrt holds root, orphan holds none, the open fixture has zero members" \
|| warden_fail "WS-3: membership matrix is wrong (dual_root=$D_ROOT rooter_root=$R_ROOT rooter_n=$R_N orphan=$O_N oadmin_root=$OA_ROOT oadminrt_root=$OART_ROOT open_members=$OPEN_N) — every listing predicate below would prove nothing"
# --- marker signals ----------------------------------------------------------
# One in root (andrew authors it as a real root member — no temporary grant
# needed) and one in alpha. Only two rows: #391 is about the workspace's
# existence in menus, and SR-* is a corroboration of #387, not a re-run of it.
mk_signal() {
local jar="$1" ws="$2" label="$3" eid
eid=$(ws_json "$jar" "$ws" -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg t "warden391 $label parent" --arg c "$MARK parent entry for $label" \
'{title:$t, content:$c, type:"thought"}')" \
"$SERVER_BASE/entries" | jq -r '.lookupKey // .id // empty')
[ -z "$eid" ] && { echo ""; return; }
ws_json "$jar" "$ws" -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg t "warden391 $label" --arg c "$MARK $label scoped signal" \
--arg e "$eid" '{title:$t, content:$c, entryId:$e, tags:[]}')" \
"$SERVER_BASE/signals" | jq -r '.lookupKey // .id // empty'
}
# The write path refuses a PRIVATE workspace the author holds no row in —
# super_admin does NOT bypass it, and for an OPEN workspace it has been observed
# to silently write into ROOT instead of refusing (plan 23, 20260820). Grant
# andrew a TEMPORARY alpha membership for authoring only; it is removed and the
# removal is proven before any listing predicate runs.
warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role)
VALUES ('$ANDREW_ID-t391-$WS_A', '$WS_A', '$ANDREW_ID', 'admin')
ON CONFLICT (workspace_id, user_id) DO NOTHING"
SIG_R=$(mk_signal "$JAR_A" "$ROOT_ID" rootonly)
SIG_A=$(mk_signal "$JAR_A" "$WS_A" alphaonly)
for v in SIG_R SIG_A; do printf '%s\n' "${!v}" > "$S/$v"; done
{ [ -n "$SIG_R" ] && [ -n "$SIG_A" ]; } \
&& warden_pass "WS-4: two marker signals seeded (root=$SIG_R alpha=$SIG_A)" \
|| warden_fail "WS-4: a marker signal failed to create (root=$SIG_R alpha=$SIG_A) — SR-1/SR-2 cannot distinguish absence-by-scope from absence-by-missing-row"
# WS-4b: each marker actually LANDED where it was meant to. A root marker that
# silently landed in alpha turns SR-1 into a vacuous pass.
P_R=$(warden_psql_one "SELECT workspace_id FROM signals WHERE lookup_key='$SIG_R'")
P_A=$(warden_psql_one "SELECT workspace_id FROM signals WHERE lookup_key='$SIG_A'")
{ [ "$P_R" = "$ROOT_ID" ] && [ "$P_A" = "$WS_A" ]; } \
&& warden_pass "WS-4b: both markers landed in their intended workspaces — no silent write fallback distorted the fixture" \
|| warden_fail "WS-4b: markers landed wrong (root marker in $P_R, alpha marker in $P_A) — SR-1's negative would pass vacuously"
# WS-5: the root marker is findable AT ALL by someone allowed to see it, before
# any "absent" claim rests on it.
PROBE=$(ws_json "$JAR_A" "$ROOT_ID" "$SERVER_BASE/search?q=$MARK&mode=bm25&limit=50")
printf '%s' "$PROBE" | grep -q "$SIG_R" \
&& warden_pass "WS-5: the root marker is indexed and retrievable by a root member — every later 'absent' means scoped out, not never written" \
|| warden_fail "WS-5: the root marker is not retrievable by its own author inside root (${PROBE:0:200}) — the fixture never got indexed and SR-1 is meaningless"
warden_psql_exec "DELETE FROM workspace_members WHERE id = '$ANDREW_ID-t391-$WS_A'"
TMP_LEFT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE id LIKE '$ANDREW_ID-t391-%'")
[ "$TMP_LEFT" = "0" ] \
&& warden_pass "WS-5b: andrew's temporary authoring membership in the alpha fixture is gone again — the membership matrix WS-3 asserted is what the predicates run against" \
|| warden_fail "WS-5b: $TMP_LEFT temporary authoring memberships remain — the fixture no longer matches WS-3"
# --- mint one MCP token per fixture identity ---------------------------------
# There is no HTTP endpoint that mints a token for an arbitrary user (POST
# /users/regenerate-mcp is self-only and returns the URL WITHOUT the token), so
# mint in-process through the product's own signer, and provision the keypair
# the same way the queue worker does — signing in never creates one.
: "${KEY_ENCRYPTION_SECRET:?KEY_ENCRYPTION_SECRET missing from the warden env file — signMcpToken cannot decrypt any keypair}"
cat > "$PROJECT_ROOT/server/.warden-391-mint.mts" <<'MINT'
import { eq } from 'drizzle-orm'
import { generateKeypair, signMcpToken } from './src/core/keypair/index.js'
import { db } from './src/db/client.js'
import { users } from './src/db/schema.js'
const secret = process.env.KEY_ENCRYPTION_SECRET ?? ''
for (const userId of process.argv.slice(2)) {
const [u] = await db.select().from(users).where(eq(users.id, userId))
if (u && (!u.publicKey || !u.encryptedPrivateKey)) {
const { publicKey, encryptedPrivateKey } = generateKeypair(secret)
await db.update(users).set({ publicKey, encryptedPrivateKey }).where(eq(users.id, userId))
}
const tok = await signMcpToken(userId)
console.log(`${userId} ${tok ?? 'NULL'}`)
}
process.exit(0)
MINT
( cd "$PROJECT_ROOT/server" && node_modules/.bin/tsx .warden-391-mint.mts \
"$DUAL_ID" "$ROOTER_ID" "$ORPHAN_ID" "$OADMIN_ID" "$OADMRT_ID" "$ANDREW_ID" ) \
> "$S/tokens" 2>"$S/tokens.err"
rm -f "$PROJECT_ROOT/server/.warden-391-mint.mts"
tok_for() { grep "^$1 " "$S/tokens" | awk '{print $2}'; }
TOK_DUAL=$(tok_for "$DUAL_ID"); TOK_ROOTER=$(tok_for "$ROOTER_ID")
TOK_ORPHAN=$(tok_for "$ORPHAN_ID"); TOK_OADMIN=$(tok_for "$OADMIN_ID")
TOK_OADMRT=$(tok_for "$OADMRT_ID"); TOK_ANDREW=$(tok_for "$ANDREW_ID")
for v in TOK_DUAL TOK_ROOTER TOK_ORPHAN TOK_OADMIN TOK_OADMRT TOK_ANDREW; do
printf '%s\n' "${!v}" > "$S/$v"
done
if printf '%s%s%s%s%s%s' "$TOK_DUAL" "$TOK_ROOTER" "$TOK_ORPHAN" "$TOK_OADMIN" \
"$TOK_OADMRT" "$TOK_ANDREW" | grep -q 'NULL' || [ -z "$TOK_DUAL" ]; then
tail -5 "$S/tokens.err"
warden_fail "WS-6: MCP token minting returned NULL/empty for at least one identity — the mint script provisions the keypair itself (sign-in does NOT); see $S/tokens.err"
else
warden_pass "WS-6: an MCP token is minted for each of the six identities"
fi
# WS-7: the transport leg works, and offers the tool under test, before any
# scope claim rests on it.
TOOLS=$(curl -s -X POST -H 'accept: application/json, text/event-stream' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
"$SERVER_BASE/mcp?token=$TOK_DUAL")
printf '%s' "$TOOLS" | grep -q '"list_workspaces"' \
&& warden_pass "WS-7: POST /mcp?token= answers tools/list and offers 'list_workspaces'" \
|| warden_fail "WS-7: the MCP transport did not offer list_workspaces (${TOOLS:0:300}) — every ML-* below would red for a harness reason, not a product one"
GET /workspaces, the feed behind every app pickerset -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-391; source "$S/helpers.sh"
ROOT_ID="$(cat $S/ROOT_ID)"; WS_A="$(cat $S/ws-alpha)"; WS_O="$(cat $S/ws-open)"
# There is exactly ONE workspace-enumerating endpoint in the product, and the
# switcher (app/src/components/layout/WorkspaceSwitcher.tsx), the /workspaces
# page, /admin/workspaces and the member-assignment picker all reach it through
# the same generated client fn `listWorkspaces` + `useWorkspaces()`. Asserting
# this response IS asserting all four app surfaces.
L_DUAL=$(ws_json "$S/jar-dual" - "$SERVER_BASE/workspaces")
[ "$(parses "$L_DUAL")" = "yes" ] \
&& warden_pass "HL-0: GET /workspaces answered the plain member with a parseable, non-empty workspace list — the negatives below mean 'absent', not 'unreadable'" \
|| warden_fail "HL-0: GET /workspaces returned nothing parseable for the plain member (${L_DUAL:0:200}) — HL-1 would pass vacuously; fix the harness or the endpoint before reading any other HL line"
[ "$(listed "$L_DUAL" "$ROOT_ID")" = "no" ] \
&& warden_pass "HL-1 [load-bearing]: the root workspace is ABSENT from GET /workspaces for a member of alpha who holds no root membership — the switcher, the workspaces page, the admin list and the member picker all stop offering it" \
|| warden_fail "HL-1 [load-bearing]: root ($ROOT_ID) is still listed to a non-member. Root is provisioned visibility='open', so it reaches this caller through the open arm of packages/permissions/src/ability.ts:428-431 — deleting the MCP union in mcp/server.ts alone does not touch this path. This is the defect #391 was filed for"
[ "$(listed "$L_DUAL" "$WS_A")" = "yes" ] \
&& warden_pass "HL-2a: their own workspace is still listed" \
|| warden_fail "HL-2a: the caller's own workspace ($WS_A) vanished from their list — the fix narrowed the listing far past root"
[ "$(listed "$L_DUAL" "$WS_O")" = "yes" ] \
&& warden_pass "HL-2 [shape guard]: a NON-ROOT open workspace they hold no membership in is STILL listed — root was carved out specifically, rather than the open-visibility arm being deleted wholesale" \
|| warden_fail "HL-2 [shape guard]: the non-root open workspace ($WS_O) is also gone. #391 hides ROOT, not every open workspace; removing can('read','Workspace',{visibility:'open'}) from ability.ts:428-431 is the shortest way to green HL-1 and it silently changes discovery for the whole product. Carve root out instead"
# The orphan: zero memberships anywhere. Their list is the sharpest form of the
# rule — nothing they can see is theirs, and root must not be the consolation.
L_ORPH=$(ws_json "$S/jar-orphan" - "$SERVER_BASE/workspaces")
[ "$(listed "$L_ORPH" "$ROOT_ID")" = "no" ] \
&& warden_pass "HL-3: a caller with ZERO workspace memberships is not shown root either" \
|| warden_fail "HL-3: root came back to a caller who belongs to no workspace at all — the listing is still visibility-driven, not membership-driven"
# THE BEHAVIOUR CHANGE. An org_admin with no root membership row.
L_OA=$(ws_json "$S/jar-oadmin" - "$SERVER_BASE/workspaces")
[ "$(listed "$L_OA" "$ROOT_ID")" = "no" ] \
&& warden_pass "HL-4 [load-bearing, the behaviour change]: an org_admin who holds NO root membership row does not see root in GET /workspaces — the always-surface-root-for-org-roles convenience is retired on the HTTP surface too" \
|| warden_fail "HL-4 [load-bearing, the behaviour change]: the org_admin still sees root without holding a membership row. Note this one does NOT go green by flipping root's visibility to 'private' — org_admin's manage-all-in-org rule (ability.ts:242) ignores visibility, so an explicit isRoot carve-out is required. This predicate is the whole reason a visibility flip is not a complete fix"
[ "$(listed "$L_OA" "$WS_A")" = "yes" ] \
&& warden_pass "HL-4b: …while that same org_admin still sees the non-root workspaces they administer — org-wide administration is intact" \
|| warden_fail "HL-4b: the org_admin lost their non-root workspaces too — the carve-out was applied to the whole admin rule instead of to root"
# --- the members-only reading, from the other side ---------------------------
L_ROOTER=$(ws_json "$S/jar-rooter" - "$SERVER_BASE/workspaces")
[ "$(listed "$L_ROOTER" "$ROOT_ID")" = "yes" ] \
&& warden_pass "HL-5 [load-bearing regression guard]: a PLAIN member whose only membership is root still sees root — the membership row alone carries it, with no role rule to fall back on" \
|| warden_fail "HL-5 [load-bearing regression guard]: a genuine root member cannot see their own workspace. #391 hides root from NON-members; this fix hid it from everyone, which is the stricter reading the issue asks to be told about explicitly before it ships"
L_OART=$(ws_json "$S/jar-oadminrt" - "$SERVER_BASE/workspaces")
[ "$(listed "$L_OART" "$ROOT_ID")" = "yes" ] \
&& warden_pass "HL-6: an org_admin who DOES hold a root membership row still sees root — the retirement removed the roles-imply-root convenience, not admins' access" \
|| warden_fail "HL-6: an org_admin holding a real root membership row lost root — the carve-out was written as 'admins never see root' instead of 'nobody sees root without a row'"
L_AND=$(ws_json "$S/jar-andrew" - "$SERVER_BASE/workspaces")
[ "$(listed "$L_AND" "$ROOT_ID")" = "yes" ] \
&& warden_pass "HL-7 [regression guard]: andrew — the real super admin and a real root member — still sees root in the list the app renders" \
|| warden_fail "HL-7 [regression guard]: the owner lost the Owner's space from his own switcher. This is the first thing a human will check by hand"
# Listed is not the same as usable. Root must still RESOLVE for its members.
R_CODE=$(curl -s -o "$S/rooter-ws.json" -w '%{http_code}' -b "$S/jar-rooter" \
-H "Origin: $SERVER_BASE" "$SERVER_BASE/workspaces/$ROOT_ID")
[ "$R_CODE" = "200" ] \
&& warden_pass "HL-8 [regression guard]: a root member can still fetch the root workspace by id (HTTP 200) — hidden from menus for others did not become 404 for its own members" \
|| warden_fail "HL-8 [regression guard]: GET /workspaces/$ROOT_ID answered HTTP $R_CODE to one of root's own members — the masking was applied to everyone instead of to non-members"
# And the open fixture is still reachable by id for a non-member, matching HL-2.
O_CODE=$(curl -s -o /dev/null -w '%{http_code}' -b "$S/jar-dual" \
-H "Origin: $SERVER_BASE" "$SERVER_BASE/workspaces/$WS_O")
[ "$O_CODE" = "200" ] \
&& warden_pass "HL-9 [shape guard]: the non-root OPEN workspace still resolves by id for a non-member — the open-visibility read path was left where it was" \
|| warden_fail "HL-9 [shape guard]: GET /workspaces/$WS_O answered HTTP $O_CODE to a non-member. Together with HL-2 this says the open arm was removed rather than root carved out; #391 did not ask for that"
list_workspaces and the retired org-role unionset -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-391; source "$S/helpers.sh"
ROOT_ID="$(cat $S/ROOT_ID)"; WS_A="$(cat $S/ws-alpha)"
TOK_DUAL="$(cat $S/TOK_DUAL)"; TOK_ROOTER="$(cat $S/TOK_ROOTER)"
TOK_OADMIN="$(cat $S/TOK_OADMIN)"; TOK_OADMRT="$(cat $S/TOK_OADMRT)"
TOK_ANDREW="$(cat $S/TOK_ANDREW)"; TOK_ORPHAN="$(cat $S/TOK_ORPHAN)"
M_DUAL=$(mcp_text "$TOK_DUAL" list_workspaces '{}')
[ "$(parses "$M_DUAL")" = "yes" ] \
&& warden_pass "ML-0: list_workspaces answered the plain member with a parseable, non-empty list" \
|| warden_fail "ML-0: list_workspaces returned nothing parseable (${M_DUAL:0:200}) — ML-1 would pass vacuously"
[ "$(listed "$M_DUAL" "$ROOT_ID")" = "no" ] \
&& warden_pass "ML-1 [load-bearing]: the MCP workspace list omits root for a caller who holds no root membership row" \
|| warden_fail "ML-1 [load-bearing]: root ($ROOT_ID) is still in the MCP list for a non-member — mcp/server.ts:828-840 unions the org's is_root workspace onto callerWorkspaceIds by role, with no membership check"
# The id-independent form of the same claim. `list_workspaces` ships an isRoot
# flag on every row (server.ts:846-854), so an assistant can see rootness even
# if the id means nothing to it. No row may carry it for this caller.
NROOT=$(printf '%s' "$M_DUAL" | jq -r '[ (if type=="array" then . else (.workspaces? // []) end)[] | select(.isRoot == true) ] | length' 2>/dev/null || echo -1)
[ "$NROOT" = "0" ] \
&& warden_pass "ML-2 [load-bearing]: not one row in the caller's MCP workspace list is flagged isRoot — the omission holds by flag, not just by id" \
|| warden_fail "ML-2 [load-bearing]: $NROOT row(s) flagged isRoot:true came back to a non-member. If ML-1 is green and this is red, root is being returned under a different id than the one this org's root row carries — investigate before treating ML-1 as evidence"
[ "$(listed "$M_DUAL" "$WS_A")" = "yes" ] \
&& warden_pass "ML-3: their own workspace is still in the MCP list — the tool did not go blind" \
|| warden_fail "ML-3: the caller's own workspace ($WS_A) is missing from list_workspaces — callerWorkspaceIds regressed while root was being removed"
M_ORPH=$(mcp_text "$TOK_ORPHAN" list_workspaces '{}')
[ "$(listed "$M_ORPH" "$ROOT_ID")" = "no" ] \
&& warden_pass "ML-3b: a caller with zero memberships gets no root row from MCP either" \
|| warden_fail "ML-3b: root came back over MCP to a caller who belongs to no workspace at all"
# THE BEHAVIOUR CHANGE, on the transport where the convenience is written down.
M_OA=$(mcp_text "$TOK_OADMIN" list_workspaces '{}')
[ "$(listed "$M_OA" "$ROOT_ID")" = "no" ] \
&& warden_pass "ML-4 [load-bearing, the behaviour change]: an org_admin without a root membership row no longer receives root from list_workspaces — the org-role union at mcp/server.ts:828-840 is retired" \
|| warden_fail "ML-4 [load-bearing, the behaviour change]: the org_admin still receives root over MCP without holding a membership row. This is the exact convenience #391 names: 'today it adds root as a write target for org_admin/super_admin even without a membership row'"
# The members-only reading, both directions, on MCP too.
M_ROOTER=$(mcp_text "$TOK_ROOTER" list_workspaces '{}')
[ "$(listed "$M_ROOTER" "$ROOT_ID")" = "yes" ] \
&& warden_pass "ML-5 [regression guard]: a plain member whose only membership is root still receives root from list_workspaces" \
|| warden_fail "ML-5 [regression guard]: a genuine root member's MCP list no longer contains root — the union was replaced by an is_root blocklist instead of by a membership check"
M_AND=$(mcp_text "$TOK_ANDREW" list_workspaces '{}')
M_OART=$(mcp_text "$TOK_OADMRT" list_workspaces '{}')
{ [ "$(listed "$M_AND" "$ROOT_ID")" = "yes" ] && [ "$(listed "$M_OART" "$ROOT_ID")" = "yes" ]; } \
&& warden_pass "ML-6 [regression guard]: andrew and the org_admin who holds a real root row both still receive root over MCP — role no longer grants it, a membership row still does" \
|| warden_fail "ML-6 [regression guard]: a real root MEMBER lost root over MCP (andrew=$(listed "$M_AND" "$ROOT_ID") admin-with-row=$(listed "$M_OART" "$ROOT_ID")) — the retirement removed the row-based path along with the role-based 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-391; source "$S/helpers.sh"
ROOT_ID="$(cat $S/ROOT_ID)"; ROOT_SLUG="$(cat $S/ROOT_SLUG)"
TOK_DUAL="$(cat $S/TOK_DUAL)"
# The bogus references. Well-formed, same shape as the real ones, guaranteed
# never to have existed.
GHOST_SLUG="warden391-ghost-$RUN_TS"
GHOST_ID="wd391ghost$RUN_TS"
# Normalise: replace whichever ref appears in the body with a placeholder, so
# two answers that differ ONLY by the name the caller supplied compare equal.
# Anything else that differs is an oracle.
norm() { printf '%s' "$2" | sed -e "s/$1/<REF>/g" -e "s/$ROOT_ID/<REF>/g"; }
# --- pair 1: an MCP workspace ref --------------------------------------------
A_ROOT=$(mcp_call "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$ROOT_SLUG" \
'{q:$q, mode:"bm25", limit:5, workspace:$w}')")
A_GHOST=$(mcp_call "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$GHOST_SLUG" \
'{q:$q, mode:"bm25", limit:5, workspace:$w}')")
N_ROOT=$(norm "$ROOT_SLUG" "$A_ROOT"); N_GHOST=$(norm "$GHOST_SLUG" "$A_GHOST")
{ [ -n "$N_ROOT" ] && [ "$N_ROOT" = "$N_GHOST" ]; } \
&& warden_pass "XM-1 [load-bearing]: over MCP, naming the hidden root workspace produces a byte-identical answer to naming a workspace that never existed — a non-member has no oracle for root's existence" \
|| warden_fail "XM-1 [load-bearing]: the two answers differ. root: ${N_ROOT:0:220} ghost: ${N_GHOST:0:220}. Hiding root from the menu is worthless if naming it still confirms it is there; workspace-ref.ts already masks the in-org non-member case with the same 'not found in your organization' wording it uses for a cross-org ref — that must keep holding for root"
# And the masked answer is actually one of the product's OWN masking shapes, so
# XM-1 cannot be satisfied by two identically-broken answers (a pair of 500s
# compares equal). The search tool does NOT route through
# resolveReadWorkspaceId's refusal: #387 (plan 23's contract) has
# resolveSearchWorkspaceIds degrade a bad/non-member ref to the C-07
# empty-but-legal envelope (results:[], mcp/server.ts ~:1222). Accept either
# shape — refusal or empty envelope — but never an error frame, and never a
# silent fallback to the caller's own scope (their alpha marker matches $MARK
# and must not surface under a root ref).
SIG_A="$(cat $S/SIG_A)"
if printf '%s' "$A_ROOT" | grep -q 'not found in your organization'; then
warden_pass "XM-2: the masked answer is the product's existence-masking refusal ('not found in your organization'), not an incidental matching failure"
elif printf '%s' "$A_ROOT" | tr -d '\\' | grep -q '"results":\[\]' \
&& ! printf '%s' "$A_ROOT" | grep -q '"isError":true' \
&& ! printf '%s' "$A_ROOT" | grep -q "$SIG_A"; then
warden_pass "XM-2: the masked answer is #387's empty-but-legal search envelope (results:[], no error, and no silent fallback to the caller's own scope) — the same C-07 shape a nonexistent ref receives"
else
warden_fail "XM-2: the answer for a hidden root ref is neither the standard masking refusal nor the C-07 empty envelope (${A_ROOT:0:220}) — XM-1 may be comparing two identically-broken responses"
fi
# --- pair 2: GET /workspaces/:id ---------------------------------------------
RC=$(curl -s -o "$S/xm-root.json" -w '%{http_code}' -b "$S/jar-dual" \
-H "Origin: $SERVER_BASE" "$SERVER_BASE/workspaces/$ROOT_ID")
GC=$(curl -s -o "$S/xm-ghost.json" -w '%{http_code}' -b "$S/jar-dual" \
-H "Origin: $SERVER_BASE" "$SERVER_BASE/workspaces/$GHOST_ID")
RB=$(norm "$ROOT_ID" "$(cat $S/xm-root.json)"); GB=$(norm "$GHOST_ID" "$(cat $S/xm-ghost.json)")
[ "$RC" = "$GC" ] \
&& warden_pass "XM-3 [load-bearing]: GET /workspaces/:id answers HTTP $RC for the hidden root and HTTP $GC for a nonexistent id — the same status, so the status code is not an oracle" \
|| warden_fail "XM-3 [load-bearing]: root id answered HTTP $RC, nonexistent id answered HTTP $GC. A 403-vs-404 split tells any curious member exactly which hidden workspaces are real. Mask root the way the route already masks a missing row (modules/workspaces/routes.ts returns 404 on no-row)"
[ "$RB" = "$GB" ] \
&& warden_pass "XM-4: …and the two response bodies are identical once the id is normalised out" \
|| warden_fail "XM-4: the bodies differ. root: ${RB:0:200} ghost: ${GB:0:200} — the wording is the oracle even though the status matched"
# --- pair 3: the x-workspace-id header ---------------------------------------
# This is how the app selects a workspace, so it is the header a curious member
# would poke at first.
HR=$(curl -s -o "$S/xm-hroot.json" -w '%{http_code}' -b "$S/jar-dual" \
-H "Origin: $SERVER_BASE" -H "x-workspace-id: $ROOT_ID" \
"$SERVER_BASE/search?q=$MARK&mode=bm25&limit=5")
HG=$(curl -s -o "$S/xm-hghost.json" -w '%{http_code}' -b "$S/jar-dual" \
-H "Origin: $SERVER_BASE" -H "x-workspace-id: $GHOST_ID" \
"$SERVER_BASE/search?q=$MARK&mode=bm25&limit=5")
HRB=$(norm "$ROOT_ID" "$(cat $S/xm-hroot.json)"); HGB=$(norm "$GHOST_ID" "$(cat $S/xm-hghost.json)")
{ [ "$HR" = "$HG" ] && [ "$HRB" = "$HGB" ]; } \
&& warden_pass "XM-5 [load-bearing]: selecting the hidden root via x-workspace-id is answered exactly as selecting a workspace id that does not exist (HTTP $HR, identical bodies)" \
|| warden_fail "XM-5 [load-bearing]: root header → HTTP $HR ${HRB:0:160}; ghost header → HTTP $HG ${HGB:0:160}. core/authz/context.ts answers a non-member selection with 403 'You do not have access to that workspace' while an unknown id takes a different path — that split is the oracle #391 rules out"
# Nothing in any of the refusals may name the workspace beyond what the caller
# supplied. A refusal that echoes root's identity is a leak with extra steps.
LEAK=""
for f in "$S/xm-root.json" "$S/xm-hroot.json"; do
printf '%s' "$(cat "$f" 2>/dev/null)" | grep -qi 'is_root\|isRoot\|root workspace' && LEAK="$LEAK $f"
done
printf '%s' "$A_ROOT" | grep -qi 'is_root\|isRoot\|root workspace' && LEAK="$LEAK mcp-search"
[ -z "$LEAK" ] \
&& warden_pass "XM-6: none of the masked refusals mention rootness — the refusal text carries no hint that the named workspace is the Owner's space" \
|| warden_fail "XM-6: a refusal leaks rootness in:$LEAK — the caller learns the hidden workspace exists AND what it is"
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-391; source "$S/helpers.sh"
SIG_R="$(cat $S/SIG_R)"; TOK_DUAL="$(cat $S/TOK_DUAL)"; TOK_ROOTER="$(cat $S/TOK_ROOTER)"
# ONE re-assertion, not a re-litigation: #387 shipped the membership-gated scope
# contract and plan 23 owns it. It is repeated here because a listing fix can be
# built by widening reads (e.g. "root is not in the menu but is still in the
# default scope"), and because #391's own scope note leans on #387 being true.
SOUT=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
printf '%s' "$SOUT" | grep -q "$SIG_R" \
&& warden_fail "SR-1: the root marker ($SIG_R) reached a non-member's unscoped MCP search. #387's membership-gated scope has regressed underneath #391 — see plan 23 (MS-3), which owns this contract in full" \
|| warden_pass "SR-1: root content still does not reach a non-member's unscoped search — the #387 scope contract is intact beneath the listing change"
ROUT=$(mcp_text "$TOK_ROOTER" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
printf '%s' "$ROUT" | grep -q "$SIG_R" \
&& warden_pass "SR-2 [regression guard]: a root MEMBER still gets root content from search — root is listed AND usable for the people it belongs to, which is the whole members-only reading" \
|| warden_fail "SR-2 [regression guard]: a genuine root member's search returned nothing from root (${ROUT:0:200}) — root became unusable rather than unlisted"
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-391; source "$S/helpers.sh"
APP_BASE="${APP_URL:-http://localhost:8080}"
DUAL_EMAIL="$(cat $S/dual-email)"; ROOT_SLUG="$(cat $S/ROOT_SLUG)"
# Every load-bearing claim is already made at the API level in steps 2-5; this
# step is corroboration for a human, plus the one thing the API cannot show:
# WorkspaceSwitcher.tsx picks its default with `workspaces.find(w => w.isRoot)`
# (:88-92) and ExplorerGrid.tsx labels itself the same way (:249-253). With root
# gone from the feed, that find() returns undefined for most users. The unit-test
# spec below owns that branch; this asserts the visible consequence.
#
# The rendered check matches on root's SLUG, not its display name: psql strips
# whitespace, so a multi-word name read that way would never match page text.
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "AP-1/AP-2: rendered workspace switcher" "npx agent-browser is unavailable on this box"
elif ! curl -s -o /dev/null -m 5 "$APP_BASE"; then
warden_skip "AP-1/AP-2: rendered workspace switcher" "the Next.js app is not reachable on $APP_BASE"
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']" "$DUAL_EMAIL" >/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 5
npx agent-browser open "$APP_BASE/workspaces" >/dev/null
sleep 3
PAGE=$(npx agent-browser get text "body" 2>/dev/null)
if printf '%s' "$PAGE" | grep -q "$ROOT_SLUG"; then
warden_fail "AP-1: the rendered workspaces page still shows the root workspace to a non-member — the API may be clean (HL-1) while a client-side list composes root back in"
else
warden_pass "AP-1: the rendered workspaces page shows no root workspace to a non-member"
fi
if printf '%s' "$PAGE" | grep -q "warden391-alpha-$RUN_TS\|Warden 391 alpha"; then
warden_pass "AP-2: …and the page still renders the caller's own workspace — losing root did not leave the shell on an empty selection"
else
warden_fail "AP-2: the caller's own workspace is not rendered. WorkspaceSwitcher.tsx:88-92 defaults with workspaces.find(w => w.isRoot); with root gone that returns undefined and the shell can land on no selection at all. The fallback must degrade to the first available workspace"
fi
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-391; RUN_TS="$(cat $S/ts)"; ORG_ID="$(cat $S/ORG_ID)"; ROOT_ID="$(cat $S/ROOT_ID)"
# CL-2 first, because it is the one that matters: this plan wrote a marker into
# the REAL root workspace and granted memberships in it. Prove root's membership
# roll is exactly what it was plus this run's two fixtures.
FIX_ROOT_MEM=$(warden_psql_one "SELECT count(*) FROM workspace_members wm JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id='$ROOT_ID' AND u.email LIKE 'warden391-%-$RUN_TS@robin.test'")
STRAY_ROOT_MEM=$(warden_psql_one "SELECT count(*) FROM workspace_members wm JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id='$ROOT_ID' AND u.email LIKE 'warden391-%@robin.test' AND u.email NOT LIKE '%-$RUN_TS@robin.test'")
{ [ "$FIX_ROOT_MEM" = "2" ] && [ "${STRAY_ROOT_MEM:-0}" = "0" ]; } \
&& warden_pass "CL-2: this run holds exactly 2 fixture memberships in the real root workspace (rooter, oadminrt) and no fixtures from earlier runs linger there" \
|| warden_fail "CL-2: root's membership roll is not what this run created (this run=$FIX_ROOT_MEM, older warden391 fixtures=$STRAY_ROOT_MEM). Stale root members from a previous run make ML-4/HL-4 unreliable — clear them before trusting this run"
LEFT=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE slug LIKE 'warden391-%-$RUN_TS'")
STRAY=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE slug LIKE 'warden391-%-$RUN_TS' AND organization_id <> '$ORG_ID'")
[ "${STRAY:-0}" = "0" ] \
&& warden_pass "CL-1: $LEFT fixture workspaces left behind, all inside org $ORG_ID. Remove with: DELETE FROM workspace_members WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'warden391-%-$RUN_TS@robin.test'); DELETE FROM workspaces WHERE slug LIKE 'warden391-%-$RUN_TS'; DELETE FROM users WHERE email LIKE 'warden391-%-$RUN_TS@robin.test';" \
|| warden_fail "CL-1: $STRAY fixture workspaces landed outside org $ORG_ID — remove them before any other plan runs"
| # | Surface | How root reaches a non-member today | Predicates |
|---|---|---|---|
| A | GET /workspaces → the app switcher, /workspaces, /admin/workspaces, the member-assignment picker | root is visibility:'open', and ability.ts:428-431 grants every plain member read on open workspaces. No is_root anywhere in the read rules | HL-1 · HL-3 · HL-4 · HL-2/HL-9 (shape guard) · HL-5/HL-6/HL-7/HL-8 (members keep it) |
| B | MCP list_workspaces | mcp/server.ts:828-840 unions the org's is_root workspace on by ROLE for org_admin/super_admin, membership unread | ML-1/ML-2 · ML-3b · ML-4 · ML-5/ML-6 |
Two fix shapes pass mechanism A: an isRoot carve-out in the CASL read rules, or flipping root's visibility to private. Only the first also passes HL-4, because org_admin's manage all in org rule (ability.ts:242) never consults visibility. That is why HL-4 is marked load-bearing and why WS-0d records root's visibility at run time — a triager seeing HL-1 green and HL-4 red is looking at the visibility flip, half-done.
The likeliest wrong fix is deleting can('read','Workspace',{visibility:'open'}) outright. It greens HL-1 and HL-3 in one line, and it hides every open workspace in the product from everyone who is not a member — a discovery change #391 never asked for, and one no root-shaped predicate would notice. HL-2 and HL-9 exist only to catch it, which is why the fixture provisions an open, member-free, non-root workspace it never grants to anyone.
rooter (plain member, root only), oadminrt (org_admin holding a root row) and andrew (super_admin holding a root row) are not redundant. Only rooter proves the membership ROW is what carries root, because no role rule can be covering for it. oadminrt separates "role no longer grants root" from "admins lost root". andrew is the human check — the owner opening his own switcher — and WS-0c refuses to let that predicate run unless his membership row is real, because otherwise HL-7 is just re-asserting the super-admin blanket rule.
XM-1/XM-3/XM-5 each run the same probe twice — once naming root, once naming something that never existed — and compare after normalising the supplied ref out. Asserting one particular refusal string would be weaker in both directions: it would pass while a status-code split leaked existence, and it would fail on a harmless rewording. XM-2 exists so the comparison cannot be satisfied by two identically-broken answers (a matched pair of 500s compares equal).
handlers.ts:155-165 still falls back to root for an omitted write ref. #391 is about listing surfaces; if the fix also removes that fallback, nothing here notices, and it should get its own predicate.?token= MCP JWT, the only credential a bash harness can mint for an arbitrary user; both credentials converge on the same c.set('userId') in routes/mcp.ts.Warden proves the outcome; these prove the reasons, the branches warden cannot reach cheaply, and the shapes that must not drift. They ship with the implementation, not after it.
packages/permissions/src/ability.tsPure, no DB, and the cheapest place to pin mechanism A. Build an ability per row and assert can('read', subject('Workspace', {...})):
| caller | workspace | expected | ||
|---|---|---|---|---|
plain member, memberships {A} | root (isRoot:true, visibility:'open') | cannot read | ||
plain member, memberships {root} | root | can read | ||
plain member, memberships {A} | non-root, visibility:'open' | can read — the shape guard; this row fails a wholesale open-arm deletion | ||
plain member, memberships {A} | non-root private, not a member | cannot read (unchanged) | ||
org_admin, no root membership | root | cannot read — the behaviour change; the reason a visibility flip alone is insufficient | ||
org_admin, holds a root membership | root | can read | ||
super_admin, no root membership | root | cannot read — decide and pin it here; the issue retires the convenience for "org roles", and super_admin is the same branch one `\ | \ | ` away |
super_admin, holds a root membership | root | can read | ||
| any caller | root, ability delete | still cannot (the pre-existing ability.ts:468 rule survives the edit) | ||
viewer role in root | root | can read — a viewer is a member; do not let the viewer carve-out get copied across by reflex |
GET /workspaces — server/src/modules/workspaces/routes.test.tsThis file currently pins the old contract and will go red first; it is edited, not extended. :201 — "lists the seeded root workspace with is_root=true" — describes exactly the behaviour #391 retires. Deleting it instead of rewriting it drops the only route-level coverage of root in the list.
isRoot:true entry.:212's existing case (hides private workspaces from non-members but lists them for org admins) is kept, with root added to the org-admin half as an exclusion: an org_admin sees every private workspace except root, unless they hold a row in it.isRoot is still on the wire, because the app reads it (WorkspaceSwitcher, ExplorerGrid). Dropping the field is a tempting "hide root" shortcut that would break both components silently.GET /workspaces/:id masking — same file or a siblinglist_workspaces — server/src/mcp/__tests__/workspace-targeting.dbtest.test.ts:569-620 pins the tool's output and :615 asserts a caller receives exactly [{ id: TEST_WORKSPACE_ID, isRoot: true }] — the org-role union in action. Rewrite that block:
callerWorkspaceIds, and no returned row has isRoot:true.org_admin, no root row → same. Assert the array equals callerWorkspaceIds exactly, so the union's removal is proven by identity rather than by the absence of one id.org_admin / super_admin WITH a root row → root present, once. A duplicate is the tell that only one of the two paths (role union, membership) was removed.[].{ id, slug, name, isRoot } row shape is unchanged.callerWorkspaceIds — server/src/mcp/workspace-ref.ts:92-107Untouched by this change, and that is worth pinning: it already includes root only via a real workspace_members row, and it becomes the sole source of the MCP list. One test that a root membership row puts root in the result, one that no row keeps it out, plus the existing cross-org isolation case (workspace-isolation.dbtest.test.ts, #158) re-run against the new caller.
readableWorkspaceIds / active scope — server/src/core/authz/context.tsreadableWorkspaceIds (:256-264) is memberships ∪ open workspaces, and root is open, so root sits in nearly everyone's read set today. :224 also picks rootWorkspaceId as the active workspace whenever the caller's selection is not a membership.
readableWorkspaceIds.x-workspace-id as a non-member → the same rejection an unknown id gets. Pin the pair, matching XM-5.readable-workspaces.dbtest.test.ts currently asserts the identity readableWorkspaceIds ≡ accessibleByDrizzle(ability,'read',…); that identity must survive the ability edit, so run it unchanged as the cross-check.app/)app/package.json already runs vitest run with @testing-library/jest-dom, and WorkspaceSwitcher.test.tsx exists.
WorkspaceSwitcher.test.tsx — the fallback at :88-92 is workspaces.find(w => w.isRoot), and after this change it returns undefined for most users. New case: a list with no isRoot entry renders a selected workspace (the first one), no empty label, no crash. Keep a case where root IS present (a root member) and is selectable.ExplorerGrid.tsx:249-253 (list.find(w => w.isRoot) ?? list[0]) — the ?? list[0] already covers it; add the assertion so a later refactor cannot drop the fallback.member-workspaces.test.tsx (the admin member→workspace picker) — root is not offered as an assignable target to an admin who is not a root member; a non-root workspace still is./workspaces and /admin/workspaces pages render straight from useWorkspaces(), so one mocked-hook test each ("no root row in, no root row rendered") is enough. A client-side filter would be the wrong fix here: it leaves the API leaking and creates a second source of truth.One test whose only job is to fail if the union comes back: construct an org_admin caller with no root membership, call the list on both transports in the same file, and assert both results are exactly callerWorkspaceIds. A convenience added once for a good reason gets re-added; a test that names it in its title ('org roles no longer imply root membership (#391)') is what a future author reads before re-adding it.