Warden plan

12 - ask-edit gate (D38 suggestion intelligence: ask loop, auto-accept, inbox ranking)

← eval suite index


tier: destructive requires: [needs-postgres]


12 - ask-edit gate (D38 suggestion intelligence: ask loop, auto-accept, inbox ranking)

What it proves

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.

Prerequisites

Step 1: D38 schema + committed migration 0008

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

Step 2: suggestion policy is the single constants source

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

Step 3: ask module + POST /ask OpenAPI surface

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

Step 4: auto-accept gate at the persist seam + single apply path

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

Step 5: edit_wiki retirement + migration 0009

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

Step 6: @robin/editor — ProseIR + anchors exported, react-free

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

Step 7: dependency-cruiser boundary config carries the editor rules

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

Step 8: inbox ranking orders by the policy constants

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

Step 9: app surfaces — Ask route + screens, InboxReader warning block

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

Step 10: ask-edit integration suites pass against the branch (DB-backed)

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

Shape (note for the next author)

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