Warden plan
tier: destructive requires: [needs-postgres]
The ask-edit sprint's contract holds deterministically — no live LLM, no live server. Schema: the 5 D38 suggestion columns live on wiki_suggestions with their committed migration 0008. Policy: lib/suggestion-policy.ts is the single source of the auto-accept threshold and rank weights. Ask: the ask module barrel exists and POST /ask is a committed OpenAPI surface. Seam: the propose-edit persist path gates auto-accept on the policy threshold AND zero unsupported claims, applying through the inbox barrel's applySuggestion — never a second write path. Retirement: edit_wiki is gone from product code (negative-assertion tests keep the tombstone) and migration 0009 retires its skill-pack aliases. Editor: @robin/editor exports the ProseIR + anchor primitives and stays react-free (a node-safe package). Boundaries: .depcrc.js keeps editor table-less and all packages server-free. Ranking: the inbox list orders by the policy constants via sql.raw — not magic numbers. App: the Ask surface (/ask route, ROUTES.ask, ask screens) and the InboxReader unsupported-claims warning block are present. Finally the committed ask-edit loop + ask route suites run green against the branch.
Module isolation (ask/inbox barrels as the only cross-module surface) is enforced by dependency-cruiser and already gated by plan 01's pnpm depcruise — cited here, not re-asserted.
PROJECT_ROOT = the ask-edit tree (invoke via its .warden symlink).pnpm, jq, grep on PATH. The env file is provisioned automatically by .warden/run.sh from the tracked .warden/env/ci-env.template.sh plus the machine-local secrets file ~/.config/robin/warden-secrets.sh (mode 600, outside every git repository; override with WARDEN_SECRETS_FILE); first-time setup: mkdir -p ~/.config/robin && cp .warden/env/secrets.local.example.sh ~/.config/robin/warden-secrets.sh && chmod 600 ~/.config/robin/warden-secrets.sh.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
SCHEMA=server/src/db/schema.ts
# The suggestion table still exists.
if grep -Eq "wikiSuggestions[[:space:]]*=[[:space:]]*pgTable" "$SCHEMA"; then
warden_pass "wikiSuggestions pgTable present in db schema"
else
warden_fail "wikiSuggestions pgTable missing — the suggestion store is gone"
fi
# All 5 D38 columns declared (loose: the SQL column names, anywhere in schema.ts —
# they are unique to wiki_suggestions today).
MISSING=""
for col in "'confidence'" "'load_bearing_count'" "'unsupported_count'" "'unsupported'" "'auto_accepted'"; do
grep -q "$col" "$SCHEMA" || MISSING="$MISSING $col"
done
if [[ -z "$MISSING" ]]; then
warden_pass "all 5 D38 suggestion columns declared in schema.ts (confidence, load_bearing_count, unsupported_count, unsupported, auto_accepted)"
else
warden_fail "D38 column(s) missing from schema.ts:$MISSING"
fi
# Committed migration 0008 adds them to wiki_suggestions.
MIG=$(ls server/drizzle/migrations/0008_*.sql 2>/dev/null | head -1)
if [[ -n "$MIG" ]] && grep -q '"wiki_suggestions" ADD COLUMN "auto_accepted"' "$MIG" \
&& grep -q '"wiki_suggestions" ADD COLUMN "confidence"' "$MIG"; then
warden_pass "migration 0008 adds the D38 columns to wiki_suggestions ($(basename "$MIG"))"
else
warden_fail "migration 0008 missing or no longer adds the D38 columns"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
POL=server/src/lib/suggestion-policy.ts
if [[ -f "$POL" ]] && grep -q "export const AUTO_ACCEPT_CONFIDENCE" "$POL"; then
warden_pass "suggestion-policy exports AUTO_ACCEPT_CONFIDENCE (auto-accept threshold)"
else
warden_fail "AUTO_ACCEPT_CONFIDENCE not exported from lib/suggestion-policy.ts"
fi
if grep -q "export const RANK_CONFIDENCE_WEIGHT" "$POL" \
&& grep -q "export const RANK_LOAD_BEARING_WEIGHT" "$POL" \
&& grep -q "export const LOAD_BEARING_CAP" "$POL"; then
warden_pass "suggestion-policy exports the rank weights (RANK_CONFIDENCE_WEIGHT, RANK_LOAD_BEARING_WEIGHT, LOAD_BEARING_CAP)"
else
warden_fail "rank weight constant(s) missing from lib/suggestion-policy.ts"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# Module barrel is the only legal cross-module surface — it must export the router.
if grep -q "askRouter" server/src/modules/ask/index.ts 2>/dev/null; then
warden_pass "modules/ask barrel exists and exports askRouter"
else
warden_fail "modules/ask/index.ts missing or no longer exports askRouter"
fi
# POST /ask is a committed OpenAPI path (MCP/web clients bind to openapi.json).
if jq -e '.paths["/ask"].post' server/openapi.json >/dev/null; then
warden_pass "openapi: POST /ask is a committed surface"
else
warden_fail "openapi drift: POST /ask missing from server/openapi.json"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
PE=server/src/agent/propose-edit.ts
# The gate is a conjunction: auto mode AND zero unsupported claims AND
# confidence >= the policy threshold. Loose structural greps on its parts.
if grep -q ">= AUTO_ACCEPT_CONFIDENCE" "$PE" \
&& grep -q "unsupported.length === 0" "$PE" \
&& grep -Eq "bouncerMode.*===.*['\"]auto['\"]" "$PE"; then
warden_pass "auto-accept gate at the propose-edit persist seam (auto mode ∧ zero unsupported ∧ confidence >= AUTO_ACCEPT_CONFIDENCE)"
else
warden_fail "auto-accept gate conjunction drifted in agent/propose-edit.ts"
fi
# The threshold comes from the policy lib, not a magic number.
if grep -q "AUTO_ACCEPT_CONFIDENCE" "$PE" && grep -q "suggestion-policy" "$PE"; then
warden_pass "propose-edit imports the threshold from lib/suggestion-policy"
else
warden_fail "propose-edit no longer sources AUTO_ACCEPT_CONFIDENCE from the policy lib"
fi
# Auto-accept applies through the inbox barrel's applySuggestion — the SAME
# code path a guardian accept uses. A second write path here is the regression.
if grep -Eq "import.*applySuggestion.*from.*modules/inbox/index" "$PE"; then
warden_pass "propose-edit applies via applySuggestion from the inbox barrel (single apply path)"
else
warden_fail "propose-edit no longer imports applySuggestion from modules/inbox/index — split-brain apply risk"
fi
# And the barrel actually exports it.
if grep -q "applySuggestion" server/src/modules/inbox/index.ts; then
warden_pass "modules/inbox barrel exports applySuggestion"
else
warden_fail "applySuggestion gone from the inbox barrel"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# edit_wiki must be absent from PRODUCT code. Negative-assertion tests keep
# the name alive on purpose (they pin the retirement); migration files record
# history. Everything else is a resurrection.
LEAKS=$(grep -rl "edit_wiki" server/src packages app --include='*.ts' --include='*.tsx' 2>/dev/null \
| grep -v -E '\.test\.(ts|tsx)$' || true)
if [[ -z "$LEAKS" ]]; then
warden_pass "edit_wiki absent from all non-test product code (server/src, packages, app)"
else
warden_fail "edit_wiki resurfaced outside tests: $(echo "$LEAKS" | head -3 | tr '\n' ' ')"
fi
# The tombstone tests still exist (at least one negative assertion suite).
if grep -rq "edit_wiki" server/src --include='*.test.ts' 2>/dev/null; then
warden_pass "edit_wiki negative-assertion tests still pin the retirement"
else
warden_fail "the edit_wiki tombstone tests vanished — retirement is unpinned"
fi
# Migration 0009 retires the skill-pack aliases.
MIG9=$(ls server/drizzle/migrations/0009_*.sql 2>/dev/null | head -1)
if [[ -n "$MIG9" ]] && grep -q "edit_wiki" "$MIG9" && grep -qi "DELETE FROM" "$MIG9"; then
warden_pass "migration 0009 deletes the edit_wiki skill-pack aliases ($(basename "$MIG9"))"
else
warden_fail "migration 0009 missing or no longer retires the edit_wiki aliases"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# Package identity.
if jq -e '.name == "@robin/editor"' packages/editor/package.json >/dev/null 2>&1; then
warden_pass "packages/editor is @robin/editor"
else
warden_fail "packages/editor package name drifted"
fi
# The root barrel re-exports the ProseIR + anchor primitives (single "."
# export subpath by design — consumers import the barrel).
IDX=packages/editor/src/index.ts
if grep -q "prose-ir" "$IDX" && grep -q "anchors" "$IDX"; then
warden_pass "@robin/editor barrel re-exports prose-ir + anchors"
else
warden_fail "@robin/editor barrel lost the prose-ir/anchors re-exports"
fi
# React-free: no import statement pulls react or any /react subpath — the
# package must stay node-safe (the server imports it). Prose mentions in
# comments don't count; only import syntax.
REACTS=$(grep -rEn "^[[:space:]]*import[^;]*from[[:space:]]+['\"](react|[^'\"]*/react)" packages/editor/src 2>/dev/null || true)
if [[ -z "$REACTS" ]]; then
warden_pass "@robin/editor has zero react / */react imports (node-safe)"
else
warden_fail "react import(s) crept into @robin/editor: $(echo "$REACTS" | head -2 | tr '\n' ' ')"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# editor is in the table-less package set (no drizzle-orm import).
if grep -A8 "packages-own-no-table" .depcrc.js | grep -q "editor"; then
warden_pass ".depcrc packages-own-no-table includes editor (table-less)"
else
warden_fail "editor fell out of the packages-own-no-table rule scope"
fi
# ALL packages stay server-free (the rule scope must not narrow).
if grep -A8 "packages-no-server-import" .depcrc.js | grep -q "\^packages/"; then
warden_pass ".depcrc packages-no-server-import still scopes '^packages/' (all packages server-free)"
else
warden_fail "packages-no-server-import rule no longer scopes all of packages/"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
RT=server/src/modules/inbox/routes.ts
# The ORDER BY is built with sql.raw over the NAMED policy constants —
# a magic-number rewrite here silently decouples ranking from policy.
if grep -q "sql.raw" "$RT" \
&& grep -q "RANK_CONFIDENCE_WEIGHT" "$RT" \
&& grep -q "RANK_LOAD_BEARING_WEIGHT" "$RT"; then
warden_pass "inbox routes rank via sql.raw over RANK_CONFIDENCE_WEIGHT / RANK_LOAD_BEARING_WEIGHT"
else
warden_fail "inbox ranking no longer built from the named policy constants via sql.raw"
fi
if grep -q "suggestion-policy" "$RT"; then
warden_pass "inbox routes import the rank constants from lib/suggestion-policy"
else
warden_fail "inbox routes lost the suggestion-policy import"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# The /ask route exists in the app router (shell group may be renamed — find it).
ASK_PAGE=$(find app/src/app -type f -path "*/ask/page.tsx" | head -1)
if [[ -n "$ASK_PAGE" ]]; then
warden_pass "app /ask route page exists ($ASK_PAGE)"
else
warden_fail "no ask/page.tsx anywhere under app/src/app — the Ask route is gone"
fi
# ROUTES.ask is the named IA entry (sidebar rails bind to it).
if grep -Eq "ask:[[:space:]]*['\"]/ask['\"]" app/src/lib/routes.ts; then
warden_pass "ROUTES.ask = '/ask' in app/src/lib/routes.ts"
else
warden_fail "ROUTES.ask missing/drifted in app/src/lib/routes.ts"
fi
# Ask screen components.
if [[ -f app/src/components/screens/ask/AskScreen.tsx ]]; then
warden_pass "screens/ask/AskScreen.tsx exists (Ask surface component)"
else
warden_fail "screens/ask/AskScreen.tsx missing"
fi
# InboxReader surfaces unsupported claims as a warning block (RR-09).
IR=app/src/components/screens/inbox/InboxReader.tsx
if grep -q 'aria-label="Unsupported claims"' "$IR" && grep -q "unsupportedCount" "$IR"; then
warden_pass "InboxReader carries the unsupported-claims warning block (aria-label + unsupportedCount gate)"
else
warden_fail "InboxReader unsupported-claims warning block missing or unlabelled"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
# ask-edit.loop is DB-backed (self-provisions schema via ensureTestDatabase/
# pushTestSchema); the ask route suite mocks the db client. Both use the
# deterministic stubbed model — no live LLM.
if pnpm --filter @robin/server exec vitest run \
src/__tests__/ask-edit.loop.test.ts \
src/modules/ask/routes.test.ts \
>/tmp/warden-askedit.log 2>&1; then
warden_pass "ask-edit loop + ask route suites pass (propose→gate→apply lifecycle, POST /ask contract)"
else
tail -20 /tmp/warden-askedit.log
warden_fail "ask-edit integration tests FAILED — see /tmp/warden-askedit.log"
fi
The load-bearing seam is step 4: auto-accept must remain a POLICY decision (threshold from lib/suggestion-policy.ts) applied through the SAME applySuggestion path a guardian accept uses — if either grep reds after a refactor, check whether the gate moved files before assuming the invariant broke, and re-anchor rather than delete. Step 5's non-test sweep deliberately allows *.test.ts(x) — those are the tombstone tests pinning the retirement. The editor react-free grep matches import syntax only (comments in prose-ir.ts mention react in prose). Step 10 needs the greenlight pg container; both suites are deterministic (stubbed model, no OpenRouter).