Warden plan
tier: destructive requires: [needs-server, needs-postgres]
Acceptance sentence. A question asked through MCP is answered from exactly the workspaces you belong to — never the board's workspace by accident, never a workspace you can merely see, and belonging nowhere tells you so instead of pretending silence.
Issue #387 is one rule stated three ways: membership is the only read gate for search. Today resolveReadWorkspaceId (server/src/mcp/workspace-ref.ts) does the opposite on all three counts — an omitted workspace argument returns the org's is_root workspace with no membership check at all, an explicit ref to an open-visibility workspace resolves for a non-member, and both MCP search tools wrap the resolver in try/catch and turn every refusal into a schema-valid empty result. So: any org member can read the board's private space by simply not naming a workspace, a non-member can harvest an open workspace by naming it, and a caller who belongs to nowhere is told "nothing matched" instead of "you are not a member of any workspace".
This plan is the outcome-level floor for the fix. Every predicate is asserted against the live app on :3000 — real MCP transport (POST /mcp?token=…, JSON-RPC tools/call), real HTTP /search and /ask, real Postgres — not against the diff. The predicate blocks map 1:1 onto the ratified current-vs-target scenario table reproduced in the Shape section below; a triager reading a red assertion should be able to find its row there without reading any code.
workspace argument and gets relevant signals from both A and B — and nothing from root. Load-bearing in both directions: the union half fails a fix that narrowed the default to "your first workspace", the root half fails the fix that was never applied.workspace_members row in. Its content does not come back. This is the negative the open-visibility branch (#268/#223) fails unless search is carved out of it, and it is why the plan seeds an open workspace it never grants anyone.workspace_members rows gets an explicit failure report whose payload does not parse as a search response. Pinning the shape is the point: the defect being fixed is precisely that a refusal is currently indistinguishable from "no matches".GET /search, and the same failure report on /ask. MCP↔HTTP parity is a standing contract here (search.parity.test.ts, the shared signalSearchQuerySchema); a fix that lands on one transport only breaks it silently.tier: destructiveThe plan provisions four workspaces, four fixture identities, and ten marker rows (five signals, three wikis, plus their parent entries) in Andrew's live dev database — the same database plans 10/12/13/14/15/20/22 read. The new root-workspace signal in particular is state another plan can count. There is no DROP SCHEMA / pushTestSchema() here; this runs through the real API in plan 20/22's style. Fixture rows are per-run (RUN_TS in every name) and are left behind deliberately — deleting them would race the audit rows they wrote, and step 9 prints the one-line cleanup for an operator who wants the database quiet again.
needs-model is deliberately absent. Every search call in this plan passes mode=bm25, and both transports short-circuit the embedder on that mode (loadSearchEmbedConfig / loadEmbedConfig: if (mode === 'bm25') return undefined). The BM25 lane is to_tsquery('english', …) against signals.search_vector — no OpenRouter key, no network, meta.degraded: false. A scoping plan that needed a live model would go red for reasons that have nothing to do with scoping.
:3000 — bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh. No OpenRouter key required (see above).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 (present after pnpm install). rg is not assumed to exist on this box — every text match below is grep.robin2026 (plan 22's mk_user). Each one is signed in over HTTP (proves the credential row, yields the cookie jars step 8 needs). NOTE (corrected 20260820): sign-in does NOT generate the keypair — only /sign-up/email enqueues the provision job (modules/iam/auth.ts), so SQL-minted fixtures never get one from signing in. Step 1's mint script provisions the keypair itself via the product's generateKeypair, exactly as the provision worker does.warden_psql_one pipes through tr -d '[:space:]' — it is for scalars only. Every assertion below that touches human text (a failure message, a signal title) reads it out of the HTTP/JSON-RPC response with jq, never out of psql. And rg may be absent: grep -q throughout.
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-387
mkdir -p "$S"
echo "$SERVER_BASE" > "$S/base"; echo "$RUN_TS" > "$S/ts"
# The BM25 needle. One lowercase alphanumeric token, so `to_tsquery('english')`
# sees exactly one lexeme and stemming cannot touch it.
MARK="zqwarden387${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-387
SERVER_BASE="$(cat $S/base)"
MARK="$(cat $S/mark)"
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
}
# mk_signal <jar> <workspace-id> <label> -> signal lookupKey
# Goes through the REAL capture path so the search_vector the BM25 lane reads
# is populated by the product, not by a hand-written INSERT.
mk_signal() {
local jar="$1" ws="$2" label="$3"
local eid
eid=$(ws_json "$jar" "$ws" -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg t "warden387 $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 "warden387 $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'
}
# mk_wiki <jar> <workspace-id> <label> -> wiki lookupKey
mk_wiki() {
local jar="$1" ws="$2" label="$3"
ws_json "$jar" "$ws" -X POST -H 'Content-Type: application/json' \
-d "$(jq -cn --arg n "warden387 $MARK $label" \
'{name:$n, description:"warden 387 fixture", scope:{kind:"workspace"}, composite:false}')" \
"$SERVER_BASE/wikis" | jq -r '.lookupKey // .id // empty'
}
# mcp_call <token> <tool> <json-args> -> the JSON-RPC result frame, un-wrapped
# from SSE.
#
# The transport is STATELESS (a fresh WebStandardStreamableHTTPServerTransport
# 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 is sent:
# 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 <token> <tool> <json-args> -> the inner content[0].text payload
mcp_text() { mcp_call "$1" "$2" "$3" | jq -r '.result.content[0].text // empty'; }
# hit <payload-json> <id> -> yes/no: does a search payload contain this row?
# GUARDS: an empty payload or an empty id is ALWAYS "no". `jq -e` exits 0 on
# zero inputs, so without the guard an errored/empty response vacuously
# satisfies every positive predicate (observed 20260820: NULL tokens turned
# every MS-* answer into garbage in both directions).
hit() {
{ [ -z "$1" ] || [ -z "$2" ]; } && { echo no; return; }
printf '%s' "$1" | jq -e --arg i "$2" \
'[.results[]? | (.id // .lookupKey // .signal_id // .wiki_id // empty)] | index($i) != null' \
>/dev/null 2>&1 && 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")
{ [ -n "$ANDREW_ID" ] && [ -n "$ORG_ID" ] && [ -n "$ROOT_ID" ]; } \
&& warden_pass "WS-0b: resolved org ($ORG_ID) and its root workspace ($ROOT_ID)" \
|| 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; do printf '%s\n' "${!v}" > "$S/${v}"; done
# --- four fixture workspaces ------------------------------------------------
# alpha, beta : PRIVATE, the caller's own two — so the union predicate cannot
# be satisfied by the open-visibility branch it rules out.
# open : OPEN, and NOBODY is ever granted a membership — the visibility
# trap (row 3) and the browse regression (row 7).
# priv : PRIVATE, nobody is a member — row 4's unchanged refusal.
mk_ws() {
local slot="$1" vis="$2"
local wid="wd387${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 387 $slot $RUN_TS', 'warden387-$slot-$RUN_TS',
'warden 387 fixture', '$vis', false)"
printf '%s\n' "$wid" > "$S/ws-$slot"
printf '%s\n' "warden387-$slot-$RUN_TS" > "$S/slug-$slot"
}
mk_ws alpha private
mk_ws beta private
mk_ws open open
mk_ws priv private
WS_A="$(cat $S/ws-alpha)"; WS_B="$(cat $S/ws-beta)"
WS_C="$(cat $S/ws-open)"; WS_P="$(cat $S/ws-priv)"
CNT=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE id IN ('$WS_A','$WS_B','$WS_C','$WS_P')")
OPEN_OK=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE id='$WS_C' AND visibility='open' AND is_root=false")
PRIV_OK=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE id IN ('$WS_A','$WS_B','$WS_P') AND visibility='private'")
{ [ "$CNT" = "4" ] && [ "$OPEN_OK" = "1" ] && [ "$PRIV_OK" = "3" ]; } \
&& warden_pass "WS-1: four fixture workspaces exist — three private, one open, none root" \
|| warden_fail "WS-1: fixture workspaces did not provision as designed (count=$CNT open=$OPEN_OK private=$PRIV_OK) — the visibility trap (row 3) and the private refusal (row 4) would both be vacuous"
# --- four fixture identities ------------------------------------------------
# dual — plain member; workspace_members in A and B. NOT root, NOT C, NOT P.
# rooter — plain member; workspace_members in ROOT only.
# orphan — plain member of the ORG; ZERO workspace_members rows anywhere.
# oadmin — org_admin; workspace_members in A only.
mk_user() {
local slot="$1" orole="$2"; shift 2 # remaining args: workspace ids to join
local email="warden387-${slot}-${RUN_TS}@robin.test"
local uid="wd387${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 387 $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"
# Sign in over HTTP — proves the credential row and yields the cookie jar
# step 8 needs (the keypair is provisioned later by the mint script).
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" "$WS_B"
mk_user rooter member "$ROOT_ID"
mk_user orphan member
mk_user oadmin org_admin "$WS_A"
# 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)"
D_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$DUAL_ID'")
D_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$DUAL_ID' AND workspace_id='$ROOT_ID'")
O_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ORPHAN_ID'")
R_ROOT=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$ROOTER_ID' AND workspace_id='$ROOT_ID'")
CP_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE workspace_id IN ('$WS_C','$WS_P')")
{ [ "$D_N" = "2" ] && [ "$D_ROOT" = "0" ] && [ "$O_N" = "0" ] && [ "$R_ROOT" = "1" ] && [ "$CP_N" = "0" ]; } \
&& warden_pass "WS-3: membership matrix holds — dual in 2 non-root workspaces, rooter in root, orphan in none, open+private trap workspaces have zero members" \
|| warden_fail "WS-3: membership matrix is wrong (dual=$D_N root=$D_ROOT orphan=$O_N rooter_root=$R_ROOT trap_members=$CP_N) — the scope predicates below would prove nothing"
# --- marker rows, authored by the super admin -------------------------------
# The write path refuses a PRIVATE workspace the author holds no
# workspace_members row in — super_admin does not bypass it (observed
# 20260820: POST /entries answered "You do not have access to that workspace"
# for A/B/P). Worse, for the OPEN workspace it did not refuse — it silently
# wrote into ROOT (observed 20260820: SIG_C/WIKI_C provenance.workspace_id was
# the root id), which makes every trap predicate vacuous. Grant andrew a
# TEMPORARY membership in ALL FOUR fixtures for authoring only; it is deleted
# again right after WS-5, before any MS predicate runs, so the trap
# workspaces are back to zero members and the membership matrix the
# predicates rely on is untouched. WS-4c below asserts the actual placement.
for tws in "$WS_A" "$WS_B" "$WS_P" "$WS_C"; do
warden_psql_exec "INSERT INTO workspace_members (id, workspace_id, user_id, role)
VALUES ('$ANDREW_ID-tmp-$tws', '$tws', '$ANDREW_ID', 'admin')
ON CONFLICT (workspace_id, user_id) DO NOTHING"
done
SIG_A=$(mk_signal "$JAR_A" "$WS_A" alpha)
SIG_B=$(mk_signal "$JAR_A" "$WS_B" beta)
SIG_R=$(mk_signal "$JAR_A" "$ROOT_ID" rootonly)
SIG_C=$(mk_signal "$JAR_A" "$WS_C" openonly)
SIG_P=$(mk_signal "$JAR_A" "$WS_P" privonly)
WIKI_A=$(mk_wiki "$JAR_A" "$WS_A" alphawiki)
WIKI_R=$(mk_wiki "$JAR_A" "$ROOT_ID" rootwiki)
WIKI_C=$(mk_wiki "$JAR_A" "$WS_C" openwiki)
for v in SIG_A SIG_B SIG_R SIG_C SIG_P WIKI_A WIKI_R WIKI_C; do printf '%s\n' "${!v}" > "$S/$v"; done
{ [ -n "$SIG_A" ] && [ -n "$SIG_B" ] && [ -n "$SIG_R" ] && [ -n "$SIG_C" ] && [ -n "$SIG_P" ]; } \
&& warden_pass "WS-4: five marker signals seeded, one per workspace (A=$SIG_A B=$SIG_B root=$SIG_R open=$SIG_C priv=$SIG_P)" \
|| warden_fail "WS-4: a marker signal failed to create (A=$SIG_A B=$SIG_B root=$SIG_R open=$SIG_C priv=$SIG_P) — the scope predicates cannot distinguish absence-by-scope from absence-by-missing-row"
{ [ -n "$WIKI_A" ] && [ -n "$WIKI_R" ] && [ -n "$WIKI_C" ]; } \
&& warden_pass "WS-4b: three marker wikis seeded (A=$WIKI_A root=$WIKI_R open=$WIKI_C) for the search_wikis and browse halves" \
|| warden_fail "WS-4b: a marker wiki failed to create — MS-5/MS-6 (wiki search) and MS-12 (browse regression) cannot run"
# WS-4c: every marker actually LANDED in its intended workspace. The write
# path can silently fall back to root instead of refusing (observed 20260820),
# and a marker in the wrong workspace turns the trap negatives (MS-7/MS-13)
# into vacuous passes — a row nobody can reach because it does not exist there.
PLACE_BAD=""
place_ck() { # <table> <lookup-key> <intended-ws> <label>
local got; got=$(warden_psql_one "SELECT workspace_id FROM $1 WHERE lookup_key='$2'")
[ "$got" = "$3" ] || PLACE_BAD="$PLACE_BAD $4(got=$got)"
}
place_ck signals "$SIG_A" "$WS_A" sigA
place_ck signals "$SIG_B" "$WS_B" sigB
place_ck signals "$SIG_R" "$ROOT_ID" sigR
place_ck signals "$SIG_C" "$WS_C" sigC
place_ck signals "$SIG_P" "$WS_P" sigP
place_ck wikis "$WIKI_A" "$WS_A" wikiA
place_ck wikis "$WIKI_R" "$ROOT_ID" wikiR
place_ck wikis "$WIKI_C" "$WS_C" wikiC
[ -z "$PLACE_BAD" ] \
&& warden_pass "WS-4c: all eight markers landed in their intended workspaces — no silent write fallback distorted the fixture" \
|| warden_fail "WS-4c: marker(s) landed in the WRONG workspace:$PLACE_BAD — the trap negatives below would pass vacuously"
# WS-5: the rows must be findable AT ALL before any scoping claim means
# anything. If this fails, the fixture text never reached search_vector and
# every "absent" below is fake.
PROBE=$(ws_json "$JAR_A" "$WS_A" "$SERVER_BASE/search?q=$MARK&mode=bm25")
[ "$(hit "$PROBE" "$SIG_A")" = "yes" ] \
&& warden_pass "WS-5: the marker is indexed and retrievable — the super admin scoped to A finds SIG_A" \
|| warden_fail "WS-5: SIG_A is not retrievable by its own author in its own workspace ($PROBE) — the fixture never got indexed, so every 'absent' below is meaningless"
# Authoring done — drop andrew's temporary memberships so the trap workspaces
# are member-free again before any scope predicate runs, and PROVE it.
warden_psql_exec "DELETE FROM workspace_members WHERE id LIKE '$ANDREW_ID-tmp-%'"
TRAP_N=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE workspace_id IN ('$WS_C','$WS_P')")
[ "$TRAP_N" = "0" ] \
&& warden_pass "WS-5b: authoring memberships removed — the open and private trap workspaces hold zero workspace_members rows again" \
|| warden_fail "WS-5b: $TRAP_N workspace_members rows remain in the trap workspaces — MS-7/MS-9 would pass or fail for the wrong reason"
# --- 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. Plan 06's fixture pattern:
# write into server/ so the app's module graph + db client resolve, run with
# the server's own tsx, remove.
: "${KEY_ENCRYPTION_SECRET:?KEY_ENCRYPTION_SECRET missing from the warden env file — signMcpToken cannot decrypt any keypair}"
# REALITY CHECK (20260820): the keypair is provisioned by a queue job that is
# enqueued ONLY on `/sign-up/email` (modules/iam/auth.ts after-hook) — a plain
# sign-in never generates one, and `users.public_key` defaults to '' NOT NULL
# (schema.ts), so `IS NOT NULL` probes lie. Fixture identities minted by SQL
# therefore never get a keypair from signing in. Provision it here through the
# product's own `generateKeypair` — byte-for-byte what the provision worker
# does (queue/worker.ts) — then sign. The sign-in above is still required: it
# proves the credential row works and yields the cookie jars steps 8 uses.
cat > "$PROJECT_ROOT/server/.warden-387-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-387-mint.mts \
"$DUAL_ID" "$ROOTER_ID" "$ORPHAN_ID" "$OADMIN_ID" ) > "$S/tokens" 2>"$S/tokens.err"
rm -f "$PROJECT_ROOT/server/.warden-387-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")
for v in TOK_DUAL TOK_ROOTER TOK_ORPHAN TOK_OADMIN; do printf '%s\n' "${!v}" > "$S/$v"; done
if printf '%s%s%s%s' "$TOK_DUAL" "$TOK_ROOTER" "$TOK_ORPHAN" "$TOK_OADMIN" | 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 fixture 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 four fixture identities"
fi
# WS-7: the transport leg works 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 '"search"' \
&& warden_pass "WS-7: POST /mcp?token= answers tools/list for a fixture identity and offers 'search'" \
|| warden_fail "WS-7: the MCP transport did not answer tools/list (${TOOLS:0:300}) — every MS-* below would red for a harness reason, not a product 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-387; source "$S/helpers.sh"
TOK_DUAL="$(cat $S/TOK_DUAL)"
SIG_A="$(cat $S/SIG_A)"; SIG_B="$(cat $S/SIG_B)"; SIG_R="$(cat $S/SIG_R)"; SIG_C="$(cat $S/SIG_C)"
WIKI_A="$(cat $S/WIKI_A)"; WIKI_R="$(cat $S/WIKI_R)"
# The whole plan in one call: NO `workspace` argument.
OUT=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
[ "$(hit "$OUT" "$SIG_A")" = "yes" ] \
&& warden_pass "MS-1 [row 1]: an unscoped MCP search returns the signal from workspace A, one of the caller's memberships" \
|| warden_fail "MS-1 [row 1]: the caller's own workspace A is missing from an unscoped search — the default scope is not the union of memberships"
[ "$(hit "$OUT" "$SIG_B")" = "yes" ] \
&& warden_pass "MS-2 [row 1]: the SAME unscoped search also returns workspace B — the default is a union, not a single workspace" \
|| warden_fail "MS-2 [row 1]: workspace B is missing while A is present — the default narrowed to one workspace instead of the union #387 chose (see the open-question note in Shape)"
[ "$(hit "$OUT" "$SIG_R")" = "no" ] \
&& warden_pass "MS-3 [row 1, load-bearing]: the root-workspace signal is ABSENT — omitting 'workspace' no longer silently resolves to the board's private space" \
|| warden_fail "MS-3 [row 1, load-bearing]: SIG_R ($SIG_R) came back to a caller who holds NO root membership — the unchecked is_root fallthrough (workspace-ref.ts, omitted-ref branch) is still live. This is the defect #387 was filed for"
[ "$(hit "$OUT" "$SIG_C")" = "no" ] \
&& warden_pass "MS-4 [row 1×3]: the open workspace the caller does not belong to contributes nothing to the default union" \
|| warden_fail "MS-4 [row 1×3]: SIG_C ($SIG_C) from an OPEN non-member workspace leaked into the default scope — the union is being built from visibility, not from workspace_members rows"
# The wiki verb carries the same rule (#387 names both search tools).
WOUT=$(mcp_text "$TOK_DUAL" search_wikis "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
[ "$(hit "$WOUT" "$WIKI_A")" = "yes" ] \
&& warden_pass "MS-5 [row 1]: search_wikis unscoped returns the caller's own workspace-A wiki" \
|| warden_fail "MS-5 [row 1]: search_wikis unscoped found nothing in workspace A — the union fix did not reach the wiki verb"
[ "$(hit "$WOUT" "$WIKI_R")" = "no" ] \
&& warden_pass "MS-6 [row 1]: search_wikis unscoped does NOT return the root-workspace wiki" \
|| warden_fail "MS-6 [row 1]: the root wiki ($WIKI_R) came back on an unscoped search_wikis — the root default was fixed on 'search' only, leaving the same hole one tool over"
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-387; source "$S/helpers.sh"
TOK_DUAL="$(cat $S/TOK_DUAL)"
SIG_A="$(cat $S/SIG_A)"; SIG_B="$(cat $S/SIG_B)"; SIG_C="$(cat $S/SIG_C)"; SIG_P="$(cat $S/SIG_P)"
SLUG_C="$(cat $S/slug-open)"; SLUG_A="$(cat $S/slug-alpha)"; SLUG_P="$(cat $S/slug-priv)"
# ROW 3 — the load-bearing negative. This workspace is visibility='open' and
# the caller holds a non-viewer membership elsewhere in the org: the exact two
# conditions the #268/#223 open branch resolves on today.
OPENOUT=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$SLUG_C" \
'{q:$q, mode:"bm25", limit:50, workspace:$w}')")
[ "$(hit "$OPENOUT" "$SIG_C")" = "no" ] \
&& warden_pass "MS-7 [row 3, load-bearing]: naming an OPEN workspace the caller holds no membership in returns none of its signals — membership is the only search gate, visibility grants zero" \
|| warden_fail "MS-7 [row 3, load-bearing]: SIG_C ($SIG_C) was returned to a non-member who merely NAMED the open workspace — the open-visibility read branch still applies to search, which is the harvest path the ruling carves out"
# The refusal may arrive as an error report or as an empty set; either satisfies
# row 3. What may not happen is content, which MS-7 already fails on. This pins
# that the response is at least well-formed and content-free.
SHAPE=$(printf '%s' "$OPENOUT" | jq -r 'if (.results? // null) == null then "error" elif (.results|length) == 0 then "empty" else "content" end' 2>/dev/null || echo error)
[ "$SHAPE" != "content" ] \
&& warden_pass "MS-8 [row 3]: the refused open-workspace search answers '$SHAPE' — legible, and carrying no rows" \
|| warden_fail "MS-8 [row 3]: the refused open-workspace search returned rows"
# ROW 4 — a PRIVATE workspace the caller is not in: refused/empty, unchanged.
# Recorded rather than fixed; #387 parks "private may scope differently" open
# by design, so this predicate only guards against the fix accidentally
# WIDENING the private case while narrowing the open one.
PRIVOUT=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$SLUG_P" \
'{q:$q, mode:"bm25", limit:50, workspace:$w}')")
[ "$(hit "$PRIVOUT" "$SIG_P")" = "no" ] \
&& warden_pass "MS-9 [row 4]: naming a PRIVATE workspace the caller holds no membership in still returns none of its signals — unchanged behaviour, held as a regression floor" \
|| warden_fail "MS-9 [row 4]: SIG_P ($SIG_P) came back from a private workspace the caller is not a member of — the scope rework widened the private case, which was never in question"
# ROW 2 — the explicit path still WORKS for a workspace the caller belongs to,
# and it NARROWS. Without MS-11, 'union by default' could be implemented as
# 'union always', quietly ignoring the argument.
NAMED=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$SLUG_A" \
'{q:$q, mode:"bm25", limit:50, workspace:$w}')")
[ "$(hit "$NAMED" "$SIG_A")" = "yes" ] \
&& warden_pass "MS-10 [row 2]: naming a workspace the caller DOES belong to still resolves and returns its signals" \
|| warden_fail "MS-10 [row 2]: naming workspace A returned nothing to one of its own members — the named path regressed while fixing the default"
[ "$(hit "$NAMED" "$SIG_B")" = "no" ] \
&& warden_pass "MS-11 [row 2]: the named search NARROWS to that workspace — workspace B is excluded" \
|| warden_fail "MS-11 [row 2]: naming workspace A still returned workspace B's signal — 'workspace' is being ignored and every search is the union, so the named-workspace contract no longer exists"
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-387; source "$S/helpers.sh"
TOK_DUAL="$(cat $S/TOK_DUAL)"
WIKI_C="$(cat $S/WIKI_C)"; SIG_C="$(cat $S/SIG_C)"; SLUG_C="$(cat $S/slug-open)"
# The browse-vs-harvest line, asserted by ONE caller against ONE workspace with
# TWO tools, in this order, on purpose: the same non-member who was refused by
# search in MS-7 must still be able to READ a wiki they navigated to in that
# open workspace. `get_wiki` and `list_wikis` keep the #268/#223 open branch by
# explicit instruction in #387 — deleting that branch outright is the natural,
# wrong way to make MS-7 pass.
# get_wiki's argument is `slug` (a wiki slug, fuzzy-matched), not a lookup
# key (confirmed 20260820: -32602 on wikiId). Resolve the slug from the row.
source "$WARDEN_LIB/db.sh"
WIKI_C_SLUG=$(warden_psql_one "SELECT slug FROM wikis WHERE lookup_key='$WIKI_C'")
BROWSE=$(mcp_text "$TOK_DUAL" get_wiki "$(jq -cn --arg s "$WIKI_C_SLUG" --arg w "$SLUG_C" \
'{slug:$s, workspace:$w}')")
if printf '%s' "$BROWSE" | grep -q "$WIKI_C\|$WIKI_C_SLUG"; then
warden_pass "MS-12 [row 7]: a non-member can still BROWSE a wiki in the open workspace (get_wiki returned $WIKI_C_SLUG) — search tightened without breaking browse"
else
LIST=$(mcp_text "$TOK_DUAL" list_wikis "$(jq -cn --arg w "$SLUG_C" '{workspace:$w}')")
printf '%s' "$LIST" | grep -q "$WIKI_C\|$WIKI_C_SLUG" \
&& warden_pass "MS-12 [row 7]: a non-member can still browse the open workspace (list_wikis returned the open wiki; get_wiki's response shape differs — collapse this fallback once the shape is confirmed)" \
|| warden_fail "MS-12 [row 7]: the non-member can no longer browse the open workspace at all (get_wiki: ${BROWSE:0:200}) — the fix removed the open-visibility branch from the READ resolver instead of carving search out of it. #387 keeps browse deliberately; this is Socrates going read-blind (#268/#223) all over again"
fi
# And the same caller, same workspace, the harvest verb: still nothing. The two
# assertions only mean something next to each other.
STILL=$(mcp_text "$TOK_DUAL" search "$(jq -cn --arg q "$MARK" --arg w "$SLUG_C" \
'{q:$q, mode:"bm25", limit:50, workspace:$w}')")
[ "$(hit "$STILL" "$SIG_C")" = "no" ] \
&& warden_pass "MS-13 [row 7]: …while SEARCH over that same open workspace still returns nothing to that same caller — the browse/harvest boundary is where #387 put it" \
|| warden_fail "MS-13 [row 7]: browse works AND search leaks for the same non-member on the same workspace — the carve-out went the wrong way round"
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-387; source "$S/helpers.sh"
TOK_ROOTER="$(cat $S/TOK_ROOTER)"; SIG_R="$(cat $S/SIG_R)"; SIG_A="$(cat $S/SIG_A)"
ROUT=$(mcp_text "$TOK_ROOTER" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
[ "$(hit "$ROUT" "$SIG_R")" = "yes" ] \
&& warden_pass "MS-14 [row 1]: a member OF the root workspace sees root content on an unscoped search — the gate is membership, not a root blocklist" \
|| warden_fail "MS-14 [row 1]: a genuine root member got no root content — the fix excluded root instead of membership-gating it, which locks the board out of its own space"
[ "$(hit "$ROUT" "$SIG_A")" = "no" ] \
&& warden_pass "MS-15 [row 1]: and that root member's union is root ONLY — workspace A, where they hold nothing, stays out" \
|| warden_fail "MS-15 [row 1]: the root member also received workspace A's signal — root membership is being treated as an org-wide read, which is the old leak facing the other direction"
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-387; source "$S/helpers.sh"
TOK_ORPHAN="$(cat $S/TOK_ORPHAN)"
RAW=$(mcp_call "$TOK_ORPHAN" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
TXT=$(printf '%s' "$RAW" | jq -r '.result.content[0].text // empty')
IS_ERR=$(printf '%s' "$RAW" | jq -r '.result.isError // false')
# MS-16 is the SHAPE pin, and it is the whole of row 5: the payload must NOT
# parse as a search response. Today it does — both tools catch the resolver
# throw and emit a schema-valid {"results":[],"meta":{…}}, byte-identical to
# "your query matched nothing".
printf '%s' "$TXT" | jq -e 'has("results")' >/dev/null 2>&1 \
&& warden_fail "MS-16 [row 5, load-bearing]: the workspace-less caller got a normal search response ($TXT) — the refusal is still indistinguishable from an empty result, which IS the defect. The catch-to-empty in mcp/server.ts's search handler must stop swallowing the membership failure" \
|| warden_pass "MS-16 [row 5, load-bearing]: a caller with ZERO workspace memberships does not receive a search-shaped payload — the refusal is distinguishable from 'no matches'"
[ "$IS_ERR" = "true" ] \
&& warden_pass "MS-17 [row 5]: the MCP tool result is flagged isError:true, so a client renders it as a failure rather than as data" \
|| warden_fail "MS-17 [row 5]: the tool result carries isError=$IS_ERR — a client has no signal that this was a refusal"
# MS-18: the report must SAY what is wrong. An assistant relays this text
# verbatim; "unknown error" would satisfy MS-16/MS-17 and help nobody.
printf '%s' "$TXT" | grep -qiE 'member|membership|no workspace|not a member' \
&& warden_pass "MS-18 [row 5]: the failure report names the condition — it mentions membership/workspace, so the caller can act on it" \
|| warden_fail "MS-18 [row 5]: the failure report does not name the condition ($TXT) — #387 asks for an explicit 'you are not a member of any workspace' response, not an opaque error"
# MS-19: same class of answer on the wiki verb — one tool fixed is half a fix.
WTXT=$(mcp_text "$TOK_ORPHAN" search_wikis "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
printf '%s' "$WTXT" | jq -e 'has("results")' >/dev/null 2>&1 \
&& warden_fail "MS-19 [row 5]: search_wikis still answers the workspace-less caller with an empty result set ($WTXT) — the failure report landed on 'search' only" \
|| warden_pass "MS-19 [row 5]: search_wikis gives the workspace-less caller the same failure report, not an empty result"
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-387; source "$S/helpers.sh"
TOK_OADMIN="$(cat $S/TOK_OADMIN)"
SIG_A="$(cat $S/SIG_A)"; SIG_B="$(cat $S/SIG_B)"; SIG_R="$(cat $S/SIG_R)"
SLUG_B="$(cat $S/slug-beta)"
# The org admin holds a workspace_members row in A only. Naming B — a PRIVATE
# workspace they hold nothing in — must still resolve: #387 keeps org-wide
# reach explicitly. Pinned as implemented.
OA_NAMED=$(mcp_text "$TOK_OADMIN" search "$(jq -cn --arg q "$MARK" --arg w "$SLUG_B" \
'{q:$q, mode:"bm25", limit:50, workspace:$w}')")
[ "$(hit "$OA_NAMED" "$SIG_B")" = "yes" ] \
&& warden_pass "MS-20 [row 6]: an org admin naming a private workspace they hold no membership in still reads it — org-wide reach is retained" \
|| warden_fail "MS-20 [row 6]: the org admin was refused workspace B — the membership gate was applied to org admins too, removing the org-wide reach #387 explicitly preserves"
# Their UNNAMED default still may not silently be root. Ruling 1 has no admin
# exemption; an org admin who holds no root membership has not asked for root.
OA_DEF=$(mcp_text "$TOK_OADMIN" search "$(jq -cn --arg q "$MARK" '{q:$q, mode:"bm25", limit:50}')")
[ "$(hit "$OA_DEF" "$SIG_A")" = "yes" ] \
&& warden_pass "MS-21 [row 6]: the org admin's unnamed search reaches their own workspace A" \
|| warden_fail "MS-21 [row 6]: the org admin's unnamed search returned nothing from their own workspace — the default scope is broken for org admins"
if [ "$(hit "$OA_DEF" "$SIG_R")" = "no" ]; then
warden_pass "MS-22 [row 6×1]: and it does NOT silently include root — the no-silent-root rule applies to org admins too"
else
ROOT_MEM=$(warden_psql_one "SELECT count(*) FROM workspace_members WHERE user_id='$(cat $S/oadmin-id)' AND workspace_id='$(cat $S/ROOT_ID)'")
warden_fail "MS-22 [row 6×1]: the org admin's UNNAMED search returned the root signal while holding $ROOT_MEM root memberships — if the implementation deliberately gives org admins an org-wide DEFAULT, that contradicts row 1 and needs recording on #387 before this predicate is relaxed"
fi
/search and /ask say the same thingset -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-387; source "$S/helpers.sh"
SIG_A="$(cat $S/SIG_A)"; SIG_B="$(cat $S/SIG_B)"; SIG_R="$(cat $S/SIG_R)"; SIG_C="$(cat $S/SIG_C)"
WS_C="$(cat $S/ws-open)"
D_JAR="$S/jar-dual"; O_JAR="$S/jar-orphan"
# No x-workspace-id header = the HTTP analogue of an omitted `workspace` arg.
# resolveOrgContext defaults that to the org root today, exactly as the MCP
# resolver does — the same defect on the web surface.
H=$(ws_json "$D_JAR" - "$SERVER_BASE/search?q=$MARK&mode=bm25&limit=50")
{ [ "$(hit "$H" "$SIG_A")" = "yes" ] && [ "$(hit "$H" "$SIG_B")" = "yes" ]; } \
&& warden_pass "MS-23 [row 8×1]: HTTP /search with no workspace header returns BOTH of the caller's workspaces — the union rule holds on the HTTP surface too" \
|| warden_fail "MS-23 [row 8×1]: HTTP /search unscoped did not return the union (A=$(hit "$H" "$SIG_A") B=$(hit "$H" "$SIG_B")) — MCP and HTTP have diverged, breaking the parity contract search.parity.test.ts exists to hold"
[ "$(hit "$H" "$SIG_R")" = "no" ] \
&& warden_pass "MS-24 [row 8×1]: and HTTP /search unscoped does not fall back to root either" \
|| warden_fail "MS-24 [row 8×1]: HTTP /search returned the root signal ($SIG_R) to a non-member of root — resolveOrgContext's root default (core/authz/context.ts) is the HTTP twin of the MCP hole and was left in place"
# Naming the open workspace the caller is not in, via the header.
HC=$(ws_json "$D_JAR" "$WS_C" "$SERVER_BASE/search?q=$MARK&mode=bm25&limit=50")
[ "$(hit "$HC" "$SIG_C")" = "no" ] \
&& warden_pass "MS-25 [row 8×3]: selecting an OPEN workspace the caller does not belong to returns none of its signals over HTTP — membership beats visibility on both transports" \
|| warden_fail "MS-25 [row 8×3]: HTTP /search served the open non-member workspace's signal ($SIG_C) — the visibility bypass survives on the web surface"
# The workspace-less caller. HTTP's documented rule today is "200 with an empty
# result, never 403" (the C-07 transport rule, pinned in
# search-workspace-scope.dbtest.test.ts). Row 5 says the failure report is owed
# on the search surfaces; if HTTP deliberately keeps 200-empty, THIS is where
# that divergence gets argued, not discovered later.
HO_CODE=$(curl -s -o "$S/orphan-http.json" -w '%{http_code}' -b "$O_JAR" \
-H "Origin: $SERVER_BASE" "$SERVER_BASE/search?q=$MARK&mode=bm25")
HO=$(cat "$S/orphan-http.json")
HO_EMPTY=$(printf '%s' "$HO" | jq -e '(.results? | length) == 0' >/dev/null 2>&1 && echo yes || echo no)
if [ "$HO_CODE" = "200" ] && [ "$HO_EMPTY" = "yes" ]; then
warden_fail "MS-26 [row 8×5]: HTTP /search answers the workspace-less caller 200 with an empty result set — indistinguishable from 'no matches', the same defect row 5 closes on MCP. Either the failure report reaches HTTP, or #387 records HTTP as a deliberate exception"
else
warden_pass "MS-26 [row 8×5]: HTTP /search gives the workspace-less caller a distinguishable failure (HTTP $HO_CODE, body ${HO:0:160})"
fi
# /ask is named explicitly by the issue as carrying the same rule.
AO_CODE=$(curl -s -o "$S/orphan-ask.json" -w '%{http_code}' -b "$O_JAR" \
-H "Origin: $SERVER_BASE" -H 'Content-Type: application/json' -X POST \
-d "$(jq -cn --arg q "$MARK" '{question:$q}')" "$SERVER_BASE/ask")
AO=$(cat "$S/orphan-ask.json")
printf '%s' "$AO" | grep -qiE 'member|membership|no workspace' \
&& warden_pass "MS-27 [row 8×5]: /ask tells the workspace-less caller they belong to no workspace (HTTP $AO_CODE)" \
|| warden_fail "MS-27 [row 8×5]: /ask answered the workspace-less caller with HTTP $AO_CODE and no membership explanation (${AO:0:200}) — #387 covers /ask as well as the MCP search tools"
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-387; RUN_TS="$(cat $S/ts)"; ORG_ID="$(cat $S/ORG_ID)"
# Not a cleanup — a receipt. The fixture rows persist on purpose (they carry
# audit rows); this states exactly what an operator would remove and confirms
# nothing escaped into another organization.
LEFT=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE slug LIKE 'warden387-%-$RUN_TS'")
STRAY=$(warden_psql_one "SELECT count(*) FROM workspaces WHERE slug LIKE 'warden387-%-$RUN_TS' AND organization_id <> '$ORG_ID'")
[ "${STRAY:-0}" = "0" ] \
&& warden_pass "CL-1: $LEFT fixture workspaces left behind, all inside org $ORG_ID — nothing crossed an organization boundary. Remove with: DELETE FROM workspaces WHERE slug LIKE 'warden387-%-$RUN_TS'; DELETE FROM users WHERE email LIKE 'warden387-%-$RUN_TS@robin.test';" \
|| warden_fail "CL-1: $STRAY fixture workspaces landed outside org $ORG_ID — remove them before any other plan runs"
Andrew ratified this current-vs-target mapping; the predicate blocks above are structured 1:1 against it, and every assertion message carries its [row N] tag so a triager can land here from a red line without reading code.
| # | Scenario | CURRENT | TARGET | Predicates |
|---|---|---|---|---|
| 1 | No workspace argument | Silently the root workspace, no membership check | The union of the caller's actual memberships; root included only via a real membership | MS-1/MS-2 (A+B together) · MS-3 (root fixture ABSENT — load-bearing) · MS-4 · MS-5/MS-6 (wiki verb) · MS-14/MS-15 (a root member DOES see root) |
| 2 | Names a workspace they are a member of | That workspace only | Unchanged | MS-10 · MS-11 (it still narrows) |
| 3 | Names an OPEN workspace they are not a member of | Returns results | Returns nothing — membership is the only search gate, visibility grants zero | MS-7 (load-bearing negative) · MS-8 · MS-25 (HTTP) |
| 4 | Names a PRIVATE workspace they are not a member of | Refused / empty | Unchanged. "Private may scope differently" is parked open by design | MS-9 |
| 5 | Zero workspace memberships | Silent empty, indistinguishable from no-match | Explicit failure report naming the condition; the shape is pinned | MS-16 (not search-shaped — load-bearing) · MS-17 · MS-18 · MS-19 · MS-26/MS-27 (HTTP, /ask) |
| 6 | Org admin / super admin | Org-wide reach | Retained, pinned as implemented | MS-20 · MS-21 · MS-22 (but no silent root default) |
| 7 | Non-search reads (get_wiki, list_wikis, browse) | Open-visibility browse works for a non-member | Deliberately untouched — the browse-vs-harvest line | MS-12 (browse still works) · MS-13 (search over the same workspace still refuses) |
| 8 | HTTP /search and /ask | Same root default, same silent empty | Same contract as MCP, pinned via the parity surface | MS-23 · MS-24 · MS-25 · MS-26 · MS-27 |
Row 7 is the row most likely to be satisfied the wrong way. The shortest route to a green MS-7 is deleting the open-visibility branch from resolveReadWorkspaceId outright — which passes rows 1-6 and breaks every browse tool, reproducing the #268/#223 read-blindness that branch was added to fix. MS-12 and MS-13 are written as one adjacent pair by the same caller against the same workspace for exactly that reason.
Why the workspaces are freshly minted rather than borrowed. The negatives are the load-bearing half, and a negative is worth something only if the row it looks for definitely exists somewhere the caller cannot reach. WS-5 exists for the same reason: it proves the marker is indexed and retrievable by someone who is allowed to see it, so that every later "absent" means "scoped out" rather than "never written". A scoping plan without that probe passes perfectly against a broken indexer.
Why the open workspace has zero members. MS-7 is the only predicate separating "membership gate" from "visibility gate", and it separates them only while nobody holds a workspace_members row in it. If a later author adds one for convenience, MS-7 passes for the wrong reason. WS-3 asserts the emptiness so that cannot happen quietly.
Why mode=bm25 everywhere. Determinism, and staying off needs-model. It also keeps meta.degraded false, so a degraded-embeddings run cannot be mistaken for a scoping change.
The open question, and where it is pinned. #387 flagged union-by-default vs must-name-a-workspace and chose union (matching the intended V2 default). This plan pins union: MS-2 fails a must-name implementation, and MS-1/MS-3/MS-4 would all need rewriting if the ruling reverses. That is deliberate — the choice should cost a visible plan edit, not drift.
?token= MCP JWT, the only credential a bash harness can mint for an arbitrary user. Both credentials converge on the same c.set('userId') one line apart in routes/mcp.ts, so scope cannot differ between them — but that convergence is asserted by reading the route, not by this plan.agent/tools.ts's force-stamped active workspace. Socrates stamps a single workspace onto its read tools before they reach the resolver, so the in-product agent never exercises the omitted-ref path this plan targets. Whether that stamp should become the union is a separate question.org_admin; super_admin takes the identical branch in resolveReadWorkspaceId and transferring the root role (plan 22's SA-* steps) is too destructive to repeat for a branch that is one || away.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. Row tags refer to the table above.
resolveReadWorkspaceId's successorThe union is a list, so the read resolver's single-id signature cannot express it. Expect a sibling — resolveSearchWorkspaceIds(db, userId, workspaceRef?): Promise<string[]> — that throws a typed error (NoWorkspaceMembershipError) rather than a bare Error, because row 5's failure report must be distinguishable from an engine fault. callerWorkspaceIds already exists and is the union's obvious basis.
DB-backed (server/src/mcp/__tests__/), because every branch is a join:
| case | expected | row |
|---|---|---|
| ref omitted, memberships {A,B} | [A,B] (set equality, order-insensitive) | 1 |
| ref omitted, memberships {root} | [root] — root is in the union when earned | 1 |
ref omitted, org member, zero workspace_members rows | throws NoWorkspaceMembershipError, not [] | 5 |
ref omitted, no member row at all | throws; message names workspaces, so no-org and no-workspace collapse to one user-facing report | 5 |
| ref omitted, memberships in two ORGS | only the current org's ids (the #158 rule callerWorkspaceIds enforces — assert it survives) | 1 |
| named ref, caller is a member | [thatId], and nothing else | 2 |
| named ref, OPEN workspace, caller not a member | throws — the assertion that fails loudly if the #268/#223 branch is reused here | 3 |
| named ref, OPEN workspace, non-member, caller IS org_admin | [thatId] — resolved by the admin rule, not by visibility. Run alongside the row above to prove which branch fired | 6 |
| named ref, PRIVATE workspace, org_admin non-member | [thatId] | 6 |
| named ref, PRIVATE workspace, plain-member non-member | throws | 4 |
| named ref, foreign org | throws the existing not found in your organization message — existence masking unchanged | — |
named ref, caller's only role there is viewer | [thatId] — a viewer is a member; the viewer carve-out only ever gated the open-visibility widening, which search no longer has. Pin this or the carve-out gets copied across by reflex | 2 |
Plus the row-7 regression on the untouched function: resolveReadWorkspaceId still resolves an open non-member ref for the browse tools. Without this test, the natural fix is to delete the open branch outright and quietly break Socrates' reads.
server/src/mcp/server.tsUnit tests with the resolver and searchSignals/searchWikis mocked, asserting what the engine received:
search forwards the whole array: expect(searchSignals).toHaveBeenCalledWith(db, q, objectContaining({ workspaceIds: [A, B] })). A one-element array here is the bug in its most likely disguise. (row 1)search_wikis forwards the union as workspaceIds and keeps memberWorkspaceIds as callerWorkspaceIds(...) — two independently computed values. Once workspaceIds is the memberships, collapsing them into one variable looks like a tidy-up and silently rewrites the #132 member-broadening rule. (row 1)viewerUserId is still passed (the #287 member-wiki privacy rule).try/catch → workspaceIds = [] swallow is gone for the membership failure: a NoWorkspaceMembershipError becomes the failure report, while an unrelated engine throw still returns isError with its own message. Two tests — collapsing them re-creates the swallow one level up. (row 5)mode reaches the engine untouched; the shared-schema parse is not re-implemented.One test file owning the literal, exported as a shared constant so MCP, HTTP and /ask cannot drift into three different sentences:
{ isError: true, content: [{ type: 'text', text: <json> }] } and JSON.parse(text) has an error key.signalSearchResponseSchema.safeParse(JSON.parse(text)).success === false — the machine form of "distinguishable from empty results". This single assertion is the entire row-5 contract; write it first./member|workspace/i and names the remedy.search_wikis and for /ask.server/src/modules/search/search.parity.test.ts (row 8)This file currently pins the wrong contract and will go red first; it is edited, not extended:
it('zero-membership caller → empty results on both transports, engine untouched') becomes → the same failure report on both transports. Deleting it instead of rewriting it would drop the parity half of row 5.workspaceIds array (capture both mocks, compare sorted).The file mocks the engine and the authz layer — it pins shape, never real scoping. The DB-backed scoping half is §5/§6; the live half is this plan. A green parity file must not be allowed to stand in for either.
server/src/modules/search/search-workspace-scope.dbtest.test.tsdescribe('GET /search — single active-workspace scope') describes the old contract. Rewrite it; do not append. Left alone it stays green against a behaviour the issue retired, which is the worst outcome available to a regression suite.
x-workspace-id naming an open non-member workspace → no rows from it. (row 8×3)x-workspace-id naming a membership workspace → that workspace only. (row 2)memberWorkspaceIds / #132 broadening rule for /search/wikis is recomputed against the union, not a single active workspace./ask scope — server/src/modules/ask/routes.ts (row 8)/ask builds its deps from readableWorkspaceIds = memberships ∪ open workspaces. Row 3 makes that union wrong for a harvest surface:
[A], C absent.{ wikis: [], signals: [] }./ask union is already right; pin it so the fix does not narrow it.server/src/mcp/__tests__/workspace-isolation.dbtest.test.tsPlan 17 named this file as the place a product decision on "search = everything I can see" would land, and gated on it. Its search/search_wikis cases currently pin the single-active-workspace behaviour. Re-specify them here, then re-run bash .warden/run.sh 17-issue-158. A #387 fix that reds plan 17 is expected; a #387 fix that leaves plan 17 green did not reach the resolver.
tier: destructive / requires: [needs-server, needs-postgres] follows .warden/TIERS.md's primary-tier rule — destructive outranks both declared requirements and neither outranks it. needs-model is absent by construction (mode=bm25). Select with bash .warden/run.sh 23-issue-387.