Warden plan

23 - MCP search scope is membership-gated (issue #387)

← eval suite index


tier: destructive requires: [needs-server, needs-postgres]


23 - MCP search scope is membership-gated (issue #387)

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.

What it proves

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.

  1. Union by default, and root is not in it (row 1). A member of workspaces A and B who holds no root membership searches with NO 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.
  2. Membership beats visibility (row 3). The same member names an OPEN workspace they hold no 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.
  3. Naming works, and narrows (rows 2 and 4). A named membership workspace still resolves and excludes the caller's other workspaces; a named PRIVATE workspace they hold nothing in stays refused, unchanged.
  4. Root is a workspace like any other (row 1, read forwards). A member OF root, searching, sees root content — the gate is membership, not a root blocklist. Without this, "fix" could mean "exclude root", which locks the board out of its own space.
  5. Zero memberships is a failure, not an emptiness (row 5). A caller with no 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".
  6. Org-wide reach is retained, but not as a root default (row 6). An org admin naming a workspace they hold no membership in still resolves it. Their unnamed search still does not reach root when they hold no root membership — ruling 1 has no admin exemption.
  7. Browse is untouched (row 7). The same non-member who gets nothing from searching an open workspace can still browse a wiki in it. Search tightened; the browse-vs-harvest line the issue draws deliberately did not move. Asserted with the two calls side by side, by the same caller, in the same step — that adjacency is the evidence.
  8. The HTTP surface says the same thing (row 8). The same scoping outcomes on 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.

Why tier: destructive

The 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.

Prerequisites

The two known harness quirks

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.

Step 1: sign in, provision workspaces, identities, marker rows, MCP tokens

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"

Step 2 — ROW 1: no workspace argument ⇒ the union of my memberships, never root

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"

Step 3 — ROWS 3, 4, 2: naming a workspace (visibility grants nothing; naming still narrows)

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"

Step 4 — ROW 7: search tightened, BROWSE did not move

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"

Step 5 — ROW 1 (forwards): a member of root sees root

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"

Step 6 — ROW 5: zero memberships is a failure report, not an empty result

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"

Step 7 — ROW 6: org admin keeps org-wide reach; root is still not a default

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

Step 8 — ROW 8: HTTP /search and /ask say the same thing

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"
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"

Step 9: what this run left behind

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"

Shape (a note for the next author)

The ratified scenario table, and the predicate that carries each row

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.

#ScenarioCURRENTTARGETPredicates
1No workspace argumentSilently the root workspace, no membership checkThe union of the caller's actual memberships; root included only via a real membershipMS-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)
2Names a workspace they are a member ofThat workspace onlyUnchangedMS-10 · MS-11 (it still narrows)
3Names an OPEN workspace they are not a member ofReturns resultsReturns nothing — membership is the only search gate, visibility grants zeroMS-7 (load-bearing negative) · MS-8 · MS-25 (HTTP)
4Names a PRIVATE workspace they are not a member ofRefused / emptyUnchanged. "Private may scope differently" is parked open by designMS-9
5Zero workspace membershipsSilent empty, indistinguishable from no-matchExplicit failure report naming the condition; the shape is pinnedMS-16 (not search-shaped — load-bearing) · MS-17 · MS-18 · MS-19 · MS-26/MS-27 (HTTP, /ask)
6Org admin / super adminOrg-wide reachRetained, pinned as implementedMS-20 · MS-21 · MS-22 (but no silent root default)
7Non-search reads (get_wiki, list_wikis, browse)Open-visibility browse works for a non-memberDeliberately untouched — the browse-vs-harvest lineMS-12 (browse still works) · MS-13 (search over the same workspace still refuses)
8HTTP /search and /askSame root default, same silent emptySame contract as MCP, pinned via the parity surfaceMS-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.

Fixture design notes

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.

What is deliberately NOT asserted here

The complementary unit-test spec

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.

1. The resolver — resolveReadWorkspaceId's successor

The 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:

caseexpectedrow
ref omitted, memberships {A,B}[A,B] (set equality, order-insensitive)1
ref omitted, memberships {root}[root] — root is in the union when earned1
ref omitted, org member, zero workspace_members rowsthrows NoWorkspaceMembershipError, not []5
ref omitted, no member row at allthrows; message names workspaces, so no-org and no-workspace collapse to one user-facing report5
ref omitted, memberships in two ORGSonly the current org's ids (the #158 rule callerWorkspaceIds enforces — assert it survives)1
named ref, caller is a member[thatId], and nothing else2
named ref, OPEN workspace, caller not a memberthrows — the assertion that fails loudly if the #268/#223 branch is reused here3
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 fired6
named ref, PRIVATE workspace, org_admin non-member[thatId]6
named ref, PRIVATE workspace, plain-member non-memberthrows4
named ref, foreign orgthrows 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 reflex2

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.

2. Union derivation inside the search tools — server/src/mcp/server.ts

Unit tests with the resolver and searchSignals/searchWikis mocked, asserting what the engine received:

3. The failure report's shape (row 5)

One test file owning the literal, exported as a shared constant so MCP, HTTP and /ask cannot drift into three different sentences:

4. Parity — 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:

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.

5. HTTP DB-scoping — server/src/modules/search/search-workspace-scope.dbtest.test.ts

describe('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.

6. /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:

7. The isolation gate — server/src/mcp/__tests__/workspace-isolation.dbtest.test.ts

Plan 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.

Batch selector

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.