Warden plan

21 - a user-created wiki type generates from a neutral base, not POV (issue #367)

← eval suite index


tier: volatile requires: [needs-model, needs-server, needs-postgres]


21 - a user-created wiki type generates from a neutral base, not POV (issue #367)

What it proves

Per gh issue view 367: a wiki created under a user-defined type is generated as a Points of View wiki — POV's system message, POV's template, POV's temperature, and sections asking for a claim, the evidence behind it, and where the position breaks down. The type row saves and loads correctly; only the generated content is wrong. The cause is that pov.yaml serves as both a real shipped type and the base template every unresolved type falls back to (BASE_SPEC_FILENAME, packages/shared/src/prompts/loaders/wiki-generation.ts), and the locked fields (system_message, system_only, template) are pinned to the disk spec on purpose — so no YAML blob can undo it.

This plan asserts the OUTCOME, at the only place the issue actually makes a claim: the bytes of a generated wiki body.

  1. A custom type's Seeded body carries none of POV's framing. Create a type through the API exactly as the issue describes the broken path — the create form does not ask for a prompt, and the request schema defaults it to '' — then create a wiki under it with real captured signals and Seed it. The body contains none of POV's section signatures (the claim, the evidence behind it, where the position breaks down). This is the load-bearing negative: it is the single assertion that, red, means #367 is not fixed.
  2. It reads like its own type. The same body follows the type's own descriptors — the words the operator supplied at creation, not a generic or borrowed frame.
  3. The guardrails survive the swap. Citation markers on that body resolve to signals actually attached to the wiki, and the infobox contract holds — exactly as they do on a shipped type. Replacing the fallback must not strip the linking syntax, the rules block, or the structured-output contract, which is the entire reason the locked fields exist.
  4. A shipped canonical type still Seeds with its own framing. pov itself is still a real type; separating "the base template" from "the POV type" must not neuter POV. This is the regression predicate.
  5. wiki_types.internal_framing has a pinned disposition. The issue rules that the write-only column must be wired into generation or removed. This plan branches on which was chosen and holds the chosen one to its claim — wired ⇒ the framing text visibly influences generation; dropped ⇒ the column and its writers are gone. "Still written, never read" fails both branches.

Static and render-level checks live in Step 2 and are corroborating: they localise a failure to the loader seam, and they are the fast signal an operator reads first. None of them is the gate. A green Step 2 with a red Step 3 means the fallback was repointed but generation still resolves POV; a red Step 2 with a green Step 3 means the plan's greps are stale, not that the product is broken.

Like 20-v11-batch-issue-328, this plan drives the REAL HTTP surface against the running dev server with a REAL OpenRouter model — which is why tier: volatile. A real model's prose varies run to run. Every load-bearing assertion here is therefore a negative (a POV signature that must be absent) or a structural one (a marker that must resolve, a row that must exist); the positive prose checks are graded corroborating and warden_skip with the transcript rather than fail on a wording miss.

Prerequisites

Step 1: sign in, resolve a workspace, define the seed-and-wait helper

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)"
echo "$SERVER_BASE" > /tmp/warden-367-server-base
echo "$RUN_TS" > /tmp/warden-367-run-ts

JAR="$(mktemp /tmp/warden-367-cookies-XXXXXX.txt)"
echo "$JAR" > /tmp/warden-367-jar
SIGNIN_CODE=$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR" -X POST \
  -H 'Content-Type: application/json' -H "Origin: $SERVER_BASE" \
  -d '{"email":"andrew@robin.ai","password":"robin2026"}' \
  "$SERVER_BASE/api/auth/sign-in/email")
[ "$SIGNIN_CODE" = "200" ] \
  && warden_pass "signed in as andrew@robin.ai directly against the server (200)" \
  || warden_fail "sign-in against $SERVER_BASE returned $SIGNIN_CODE, expected 200"

export WARDEN_AUTH_STRATEGY=cookie-session
export WARDEN_AUTH_COOKIE_JAR="$JAR"
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"

WS_ID=$(warden_authed_curl "$SERVER_BASE/workspaces" | jq -r '.workspaces[0].id')
[ -n "$WS_ID" ] && [ "$WS_ID" != "null" ] \
  && warden_pass "resolved a workspace to fixture against ($WS_ID)" \
  || warden_fail "could not resolve any workspace for andrew"
echo "$WS_ID" > /tmp/warden-367-ws-id

# --- shared fixture helper, used by steps 3, 5 and 6 ---
# seed_wiki <out_prefix> <type_slug> <wiki_name> <signal_1> <signal_2> <signal_3>
# Creates a wiki of <type_slug>, attaches three real signals to it via
# POST /signals/log (the same seam the web capture rail uses), requests a Seed,
# and polls wikis.content until non-empty. Writes the wiki lookup_key to
# <out_prefix>-wiki-id and the generated body to <out_prefix>-body.md.
# Returns non-zero if no body ever landed. Real model call: 20-120s is normal.
seed_wiki() {
  local out="$1" type_slug="$2" wiki_name="$3" s1="$4" s2="$5" s3="$6"
  local wiki_json wiki_id wiki_slug code n content

  wiki_json=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg n "$wiki_name" --arg t "$type_slug" \
          '{name:$n, type:$t, scope:{kind:"workspace"}, description:"Throwaway wiki for warden plan 21 (#367)."}')" \
    "$SERVER_BASE/wikis")
  wiki_id=$(echo "$wiki_json" | jq -r '.id // .lookupKey // empty')
  wiki_slug=$(echo "$wiki_json" | jq -r '.slug // empty')
  if [ -z "$wiki_id" ] || [ -z "$wiki_slug" ]; then
    echo "seed_wiki: POST /wikis did not return id+slug: $wiki_json" >&2
    return 1
  fi
  echo "$wiki_id" > "${out}-wiki-id"
  echo "$wiki_slug" > "${out}-wiki-slug"

  local sig
  for sig in "$s1" "$s2" "$s3"; do
    code=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST \
      -H 'Content-Type: application/json' \
      -d "$(jq -cn --arg c "$sig" --arg w "$wiki_slug" '{content:$c, wikiSlug:$w}')" \
      "$SERVER_BASE/signals/log")
    case "$code" in 200|201) ;; *) echo "seed_wiki: POST /signals/log returned $code" >&2 ;; esac
  done

  # The wiki must actually be carrying signals — an unsourced Seed is a
  # different (already-covered, #328) failure and would make this plan lie.
  local attached
  attached=$(warden_psql_one "SELECT count(*) FROM edges WHERE dst_id = '$wiki_id' AND edge_type = 'SIGNAL_CITED_BY_WIKI' AND deleted_at IS NULL")
  if [ "${attached:-0}" -lt 1 ]; then
    echo "seed_wiki: no signals attached to $wiki_id — cannot Seed" >&2
    return 1
  fi

  code=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X POST \
    -H 'Content-Type: application/json' -d '{}' \
    "$SERVER_BASE/wikis/$wiki_id/regenerate")
  case "$code" in 202|200) ;; *) echo "seed_wiki: regenerate returned $code" >&2; return 1 ;; esac

  n=1
  while [ "$n" -le 60 ]; do
    content=$(warden_psql_one "SELECT coalesce(content,'') FROM wikis WHERE lookup_key = '$wiki_id'")
    if [ -n "${content// }" ]; then
      warden_psql_one "SELECT coalesce(content,'') FROM wikis WHERE lookup_key = '$wiki_id'" > "${out}-body.md"
      return 0
    fi
    sleep 3
    n=$((n + 1))
  done
  echo "seed_wiki: no body landed on $wiki_id within 180s" >&2
  return 1
}
export -f seed_wiki

Step 2: static + render-level seam checks (CORROBORATING — not the gate)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

LOADER=packages/shared/src/prompts/loaders/wiki-generation.ts
SPECS=packages/shared/src/prompts/specs/wiki-types

# SEAM-1: the spec fallback no longer names a real shipped type.
BASE_FILE=$(rg -o "BASE_SPEC_FILENAME\s*=\s*'([a-z0-9._-]+)'" -r '$1' "$LOADER" | head -1)
if [ -z "$BASE_FILE" ]; then
  warden_fail "SEAM-1: could not read BASE_SPEC_FILENAME out of $LOADER — plan grep is stale, re-read the loader before triaging as a product defect"
elif [ "$BASE_FILE" = "pov.yaml" ]; then
  warden_fail "SEAM-1: BASE_SPEC_FILENAME is still 'pov.yaml' — the base template is still a real type; every unresolved type renders as POV (#367 unfixed)"
else
  warden_pass "SEAM-1: the spec fallback points at a dedicated base spec ('$BASE_FILE'), not a shipped type"
fi
echo "${BASE_FILE:-pov.yaml}" > /tmp/warden-367-base-file

# SEAM-2: the base spec exists on disk and is NOT a copy of POV's framing.
if [ -n "$BASE_FILE" ] && [ -f "$SPECS/$BASE_FILE" ]; then
  warden_pass "SEAM-2a: the base spec file exists at $SPECS/$BASE_FILE"
  if rg -q "## The Position|## Evidence and Reasoning|## Nuances and Exceptions|## How This Shapes Behavior|POV wiki document|Conviction" "$SPECS/$BASE_FILE"; then
    warden_fail "SEAM-2b: the base spec still carries POV's framing (claim / evidence / where the position breaks down / Conviction) — it is pov.yaml under another name"
  else
    warden_pass "SEAM-2b: the base spec carries none of POV's claim-and-evidence framing"
  fi
  # The guardrails the locked fields exist to protect must be IN the base spec.
  MISSING=""
  for CONTRACT in '[LINKING SYNTAX — USE EXACTLY]' '[DOCUMENT STRUCTURE]' '[RULES — READ CAREFULLY]' '[INFOBOX]'; do
    rg -qF "$CONTRACT" "$SPECS/$BASE_FILE" || MISSING="$MISSING $CONTRACT"
  done
  [ -z "$MISSING" ] \
    && warden_pass "SEAM-2c: the base spec carries the full guardrail contract (linking syntax, structure, rules, infobox)" \
    || warden_fail "SEAM-2c: the base spec is missing guardrail block(s):$MISSING — the locked fields would ship a prompt with no citation/infobox rules"
else
  warden_fail "SEAM-2a: no base spec file found at $SPECS/${BASE_FILE:-<unresolved>}"
fi

# SEAM-3: pov.yaml survives as a real shipped type (Step 5 proves it behaves).
[ -f "$SPECS/pov.yaml" ] \
  && warden_pass "SEAM-3: pov.yaml is still present as a shipped type spec" \
  || warden_fail "SEAM-3: pov.yaml is gone — separating base-from-type deleted the POV type instead of splitting it"

# SEAM-4: the OUTPUT-SCHEMA fallback moved too. The issue names both fallbacks;
# fixing only the spec leaves a custom type validated against POV's schema.
if rg -q "\?\?\s*povWikiSchema" "$LOADER"; then
  warden_fail "SEAM-4: the output-schema fallback is still 'povWikiSchema' — only the spec fallback was repointed (#367 fix is half-applied)"
else
  warden_pass "SEAM-4: the output-schema fallback no longer resolves to povWikiSchema"
fi

# SEAM-5: BOTH creation paths compose framing. The issue calls out that neither
# the HTTP route nor the MCP tool sets it today while bootstrap does.
HANDLERS=server/src/mcp/handlers.ts
if rg -qF 'You are Quill. Generate a ' "$HANDLERS"; then
  warden_fail "SEAM-5a: the MCP create_wiki_type handler still stores the 'You are Quill. Generate a <name> document.' one-liner as the type's prompt — a non-YAML blob that fails spec validation and falls back to the disk spec anyway"
else
  warden_pass "SEAM-5a: the MCP create-type handler no longer stores the non-YAML Quill one-liner"
fi

Step 3: the load-bearing predicate — a custom type Seeds without POV's framing (live)

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="$(cat /tmp/warden-367-server-base)"
RUN_TS="$(cat /tmp/warden-367-run-ts)"
JAR="$(cat /tmp/warden-367-jar)"
export WARDEN_AUTH_STRATEGY=cookie-session WARDEN_AUTH_COOKIE_JAR="$JAR"
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"

TYPE_SLUG="warden-367-incident-${RUN_TS}"
echo "$TYPE_SLUG" > /tmp/warden-367-type-slug

# Created EXACTLY as the issue describes the broken path: the create form asks
# for name, slug and both descriptors — and no prompt. `prompt` is omitted so
# the request schema's `.default('')` applies. Do NOT add a prompt here; doing
# so tests a path the UI cannot reach.
CREATE=$(warden_authed_curl -X POST -H 'Content-Type: application/json' \
  -d "$(jq -cn --arg s "$TYPE_SLUG" \
        '{slug:$s,
          name:"Warden 367 Incident Report",
          shortDescriptor:"Incident postmortem",
          descriptor:"A factual reconstruction of a single operational incident: the timeline of what happened, the measured impact, and the remediation that followed."}')" \
  "$SERVER_BASE/preferences/wiki-types")
CREATED_SLUG=$(echo "$CREATE" | jq -r '.slug // empty')
[ "$CREATED_SLUG" = "$TYPE_SLUG" ] \
  && warden_pass "created a user-defined wiki type through the API with no prompt supplied ($TYPE_SLUG)" \
  || warden_fail "POST /preferences/wiki-types did not create the type: $CREATE"

# Precondition, stated so a later reader knows this really is the #367 path:
# the row carries no author-written prompt of its own.
ROW_PROMPT=$(warden_psql_one "SELECT coalesce(prompt,'') FROM wiki_types WHERE slug = '$TYPE_SLUG'")
ROW_USERMOD=$(warden_psql_one "SELECT user_modified FROM wiki_types WHERE slug = '$TYPE_SLUG'")
[ "$ROW_USERMOD" = "t" ] \
  && warden_pass "the new type row is user_modified — the regen override lookup will consider it" \
  || warden_fail "the new type row is not user_modified ('$ROW_USERMOD') — regen's override lookup filters it out"
echo "$ROW_PROMPT" > /tmp/warden-367-type-prompt.yaml

# CB-0: the fix's second bullet — creation composes a blob from the name and
# descriptors, so a custom type reads like its own type rather than a generic
# one. Corroborating: the OUTCOME checks below are what actually matter, and a
# fix that reaches them by another route is still a fix.
if [ -n "${ROW_PROMPT// }" ]; then
  if grep -qiE "incident|timeline|impact|remediation|Warden 367 Incident Report" /tmp/warden-367-type-prompt.yaml; then
    warden_pass "CB-0: creation composed a per-type blob carrying the type's own name/descriptors"
  else
    warden_fail "CB-0: the type stored a blob that mentions none of its own name or descriptors — composed from something else"
  fi
else
  warden_skip "CB-0 composed-blob-at-creation" "the type row's prompt is still empty; the fix may correct this purely at the fallback layer (issue bullet 1) without composing a blob (bullet 2) — CB-1..CB-4 below remain the gate"
fi

# --- the live Seed ---
seed_wiki /tmp/warden-367-custom "$TYPE_SLUG" "Warden 367 Pump Station Outage ${RUN_TS}" \
  "On 14 March the north pump station lost primary power at 02:14 and the standby generator failed to cut in; the site was dark for 41 minutes." \
  "Metered impact of the 14 March pump station outage: 1,180 households without water pressure, two clinics on backup storage, no injuries reported." \
  "Remediation after the 14 March outage: the transfer switch was replaced on 19 March and a monthly load test was added to the maintenance rota."
SEEDED=$?
[ "$SEEDED" = "0" ] \
  && warden_pass "the custom-type wiki Seeded — a body landed on wikis.content" \
  || warden_fail "the custom-type wiki never produced a body (see stderr above) — nothing to assert against; check the queue worker and the model key before treating this as a #367 defect"

BODY=/tmp/warden-367-custom-body.md
WIKI_ID="$(cat /tmp/warden-367-custom-wiki-id 2>/dev/null || echo '')"

# ── CB-1: THE LOAD-BEARING NEGATIVE ──────────────────────────────────────────
# The issue's own words for what a POV-framed body contains: "sections asking
# for a claim, the evidence behind it, and where the position breaks down".
# These are pov.yaml's default_structure headings, verbatim. An incident report
# generated from a neutral base has no reason to emit any of them.
if [ -s "$BODY" ]; then
  HITS=""
  while IFS= read -r SIG; do
    grep -qF "$SIG" "$BODY" && HITS="$HITS
  - $SIG"
  done <<'POVSIGS'
## The Position
## Evidence and Reasoning
## Nuances and Exceptions
## How This Shapes Behavior
# POV:
POVSIGS
  if [ -z "$HITS" ]; then
    warden_pass "CB-1: the custom-type body carries NONE of POV's section signatures (the claim / the evidence behind it / where the position breaks down)"
  else
    echo "--- generated body ---" >&2; cat "$BODY" >&2
    warden_fail "CB-1: the custom-type body carries POV's section signature(s):$HITS — a user-created type is still generating as a Points of View wiki (#367)"
  fi

  # CB-1b: POV's infobox row labels are as distinctive as its headings, and
  # they land in metadata rather than the body — a second, independent read of
  # the same claim.
  POV_INFOBOX=$(warden_psql_one "SELECT coalesce((SELECT count(*) FROM jsonb_array_elements(metadata->'infobox'->'rows') r WHERE r->>'label' IN ('Conviction','Contradicts')), 0) FROM wikis WHERE lookup_key = '$WIKI_ID'")
  [ "${POV_INFOBOX:-0}" = "0" ] \
    && warden_pass "CB-1b: the custom type's infobox carries none of POV's rows (Conviction / Contradicts)" \
    || warden_fail "CB-1b: the custom type's infobox carries $POV_INFOBOX POV-specific row(s) — POV's spec is still resolving for this type"
else
  warden_fail "CB-1: no body file to inspect — Seed did not complete"
fi

# ── CB-2: it follows its OWN descriptors (corroborating, prose-sensitive) ────
if [ -s "$BODY" ]; then
  MATCHED=0
  for WORD in timeline impact remediat incident outage; do
    grep -qi "$WORD" "$BODY" && MATCHED=$((MATCHED + 1))
  done
  if [ "$MATCHED" -ge 3 ]; then
    warden_pass "CB-2: the body follows the type's own descriptors (matched $MATCHED/5 descriptor terms: timeline, impact, remediation, incident, outage)"
  else
    echo "--- generated body ---" >&2; cat "$BODY" >&2
    warden_skip "CB-2 descriptor-following" "only $MATCHED/5 descriptor terms matched — real-model wording varies, so re-read the transcript above before calling this a defect. CB-1 is the load-bearing check; this one only says the body reads like the type the operator described."
  fi
fi

Step 4: the guardrails hold on the generated body exactly as on a shipped type (live)

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

BODY=/tmp/warden-367-custom-body.md
WIKI_ID="$(cat /tmp/warden-367-custom-wiki-id 2>/dev/null || echo '')"
if [ ! -s "$BODY" ] || [ -z "$WIKI_ID" ]; then
  warden_fail "CB-3/CB-4 skipped — no custom-type body from step 3"
  return 0 2>/dev/null || exit 0
fi

# ── CB-3: citation markers are present AND resolve (load-bearing) ────────────
# The locked fields exist so an override cannot strip the prompt's citation
# handling. Repointing the disk fallback changes what those locked fields ARE,
# so this is the check that the swap did not quietly drop them.
MARKERS=$(grep -oE '\[\[signal:[a-z0-9-]+\]\]' "$BODY" | sort -u)
MARKER_COUNT=$(printf '%s\n' "$MARKERS" | grep -c . || true)
if [ "${MARKER_COUNT:-0}" -ge 1 ]; then
  warden_pass "CB-3a: the custom-type body carries $MARKER_COUNT distinct signal citation marker(s) in the shipped [[signal:<slug>]] syntax"
else
  echo "--- generated body ---" >&2; cat "$BODY" >&2
  warden_fail "CB-3a: the custom-type body carries no [[signal:<slug>]] markers — the base spec's linking syntax did not reach the model, or the body is ungrounded"
fi

UNRESOLVED=0
while IFS= read -r M; do
  [ -z "$M" ] && continue
  SLUG=$(printf '%s' "$M" | sed -E 's/^\[\[signal:(.*)\]\]$/\1/')
  OK=$(warden_psql_one "SELECT count(*) FROM signals s JOIN edges e ON e.src_id = s.lookup_key AND e.edge_type = 'SIGNAL_CITED_BY_WIKI' AND e.deleted_at IS NULL WHERE s.slug = '$SLUG' AND e.dst_id = '$WIKI_ID'")
  [ "${OK:-0}" -ge 1 ] || { UNRESOLVED=$((UNRESOLVED + 1)); echo "unresolved marker: $M" >&2; }
done <<< "$MARKERS"
[ "$UNRESOLVED" = "0" ] \
  && warden_pass "CB-3b: every citation marker in the body resolves to a signal actually attached to this wiki — no invented slugs" \
  || warden_fail "CB-3b: $UNRESOLVED citation marker(s) point at slugs that are not attached signals of this wiki"

# ── CB-4: the infobox contract is present and structured (load-bearing) ──────
# The contract is that the infobox is a SEPARATE structured field, never inline
# markdown. Both halves are asserted: the key exists in metadata, and the body
# does not carry the structured field's own vocabulary.
HAS_KEY=$(warden_psql_one "SELECT (metadata ? 'infobox')::text FROM wikis WHERE lookup_key = '$WIKI_ID'")
[ "$HAS_KEY" = "true" ] \
  && warden_pass "CB-4a: the generated payload honoured the structured-output contract — wikis.metadata carries an 'infobox' key" \
  || warden_fail "CB-4a: wikis.metadata has no 'infobox' key — the base spec's output contract did not reach the model (or the output schema fallback is wrong)"

if grep -qE 'valueKind|"rows"\s*:' "$BODY"; then
  warden_fail "CB-4b: the body markdown leaks the infobox's structured vocabulary (valueKind/rows) — the infobox rules did not survive the base-spec swap"
else
  warden_pass "CB-4b: the body markdown carries no inline infobox structure — the infobox stayed a separate structured field"
fi

# CB-4c: citation processing ran on the Seeded body (the downstream half of the
# contract) — the wiki's citation edges are live and dirty_since was cleared.
DIRTY=$(warden_psql_one "SELECT coalesce(dirty_since::text,'') FROM wikis WHERE lookup_key = '$WIKI_ID'")
[ -z "$DIRTY" ] \
  && warden_pass "CB-4c: dirty_since was cleared by the Seed — the write path completed, citations included" \
  || warden_fail "CB-4c: dirty_since is still set ($DIRTY) after a completed Seed — the body landed but the citation/finalise half did not"

Step 5: regression — a shipped canonical type still Seeds with its own framing (live)

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="$(cat /tmp/warden-367-server-base)"
RUN_TS="$(cat /tmp/warden-367-run-ts)"
JAR="$(cat /tmp/warden-367-jar)"
export WARDEN_AUTH_STRATEGY=cookie-session WARDEN_AUTH_COOKIE_JAR="$JAR"
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"

POV_EXISTS=$(warden_psql_one "SELECT count(*) FROM wiki_types WHERE slug = 'pov' AND workspace_id = '$(cat /tmp/warden-367-ws-id)'")
[ "${POV_EXISTS:-0}" -ge 1 ] \
  && warden_pass "the canonical 'pov' type is seeded in this workspace" \
  || warden_fail "no 'pov' type row in this workspace — bootstrap did not seed the canonical types; step 5 cannot run"

seed_wiki /tmp/warden-367-pov pov "Warden 367 Standby Generators Are Not Redundancy ${RUN_TS}" \
  "Standby generators are treated as redundancy on paper, but the 14 March outage showed the transfer switch is a single point of failure nobody tests." \
  "Across the last four site audits, every generator started on demand and two transfer switches failed — the failure mode is the switch, not the engine." \
  "Where this stops being true: sites with dual independent feeds do not depend on a switch at all, so the argument does not carry there."
POV_SEEDED=$?
[ "$POV_SEEDED" = "0" ] \
  && warden_pass "the shipped-type (pov) wiki Seeded — a body landed on wikis.content" \
  || warden_fail "the shipped-type wiki never produced a body — cannot judge the regression predicate"

POV_BODY=/tmp/warden-367-pov-body.md
# RG-1: POV must still generate AS POV. Splitting "the base template" out of
# pov.yaml must leave the POV type itself intact. The four headings come from
# pov.yaml's default_structure ("Use this exact structure:"), so at least one
# landing is a low bar on purpose — a real model may merge or rename one.
if [ -s "$POV_BODY" ]; then
  POV_HITS=0
  for SIG in '## The Position' '## Evidence and Reasoning' '## Nuances and Exceptions' '## How This Shapes Behavior'; do
    grep -qF "$SIG" "$POV_BODY" && POV_HITS=$((POV_HITS + 1))
  done
  if [ "$POV_HITS" -ge 1 ]; then
    warden_pass "RG-1: the shipped 'pov' type still Seeds with its own framing ($POV_HITS/4 POV structure headings present)"
  else
    echo "--- pov body ---" >&2; cat "$POV_BODY" >&2
    warden_fail "RG-1: a 'pov' wiki Seeded with none of POV's own structure headings — separating the base spec from the POV type broke the POV type (regression)"
  fi

  # RG-2: and it is not now rendering as the NEUTRAL base either — i.e. the
  # split went the right way round. The base spec's own structure headings (if
  # it declares any) must not be what POV emits.
  BASE_FILE="$(cat /tmp/warden-367-base-file)"
  BASE_PATH="packages/shared/src/prompts/specs/wiki-types/$BASE_FILE"
  if [ "$BASE_FILE" != "pov.yaml" ] && [ -f "$BASE_PATH" ]; then
    BASE_HEADINGS=$(rg -o '^\s*##\s+.+$' "$BASE_PATH" | sed -E 's/^\s+//' | sort -u)
    BASE_LEAK=0
    while IFS= read -r H; do
      [ -z "$H" ] && continue
      grep -qF "$H" "$POV_BODY" && [ "$POV_HITS" -eq 0 ] && BASE_LEAK=$((BASE_LEAK + 1))
    done <<< "$BASE_HEADINGS"
    [ "$BASE_LEAK" = "0" ] \
      && warden_pass "RG-2: the pov body is not rendering the neutral base's structure — the split resolves per type, not globally" \
      || warden_fail "RG-2: the pov body carries the neutral base's structure headings and none of POV's — the fallback was made unconditional instead of a fallback"
  else
    warden_skip "RG-2 base-structure leak check" "no distinct base spec resolved (see SEAM-1); RG-1 already covers the regression"
  fi
fi

Step 6: wiki_types.internal_framing disposition is pinned (live, branching)

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="$(cat /tmp/warden-367-server-base)"
RUN_TS="$(cat /tmp/warden-367-run-ts)"
TYPE_SLUG="$(cat /tmp/warden-367-type-slug)"
JAR="$(cat /tmp/warden-367-jar)"
export WARDEN_AUTH_STRATEGY=cookie-session WARDEN_AUTH_COOKIE_JAR="$JAR"
source "$WARDEN_LIB/auth.sh"
source "$WARDEN_LIB/api.sh"

# The issue rules: "The column is written and never read. It should be wired
# into generation or dropped." This step refuses the third state — still
# written, still unread — which is the state that misdirects the NEXT
# investigation exactly as it misdirected this one.
COL=$(warden_psql_one "SELECT count(*) FROM information_schema.columns WHERE table_name = 'wiki_types' AND column_name = 'internal_framing'")

if [ "${COL:-0}" = "0" ]; then
  # ── DROPPED branch ──────────────────────────────────────────────────────
  warden_pass "IF-DISPOSITION: the column was DROPPED — wiki_types.internal_framing no longer exists"
  SCHEMA_REFS=$(rg -c 'internalFraming' server/src/db/schema.ts 2>/dev/null || echo 0)
  [ "$SCHEMA_REFS" = "0" ] \
    && warden_pass "IF-D1: the Drizzle schema no longer declares internalFraming" \
    || warden_fail "IF-D1: server/src/db/schema.ts still declares internalFraming ($SCHEMA_REFS hit(s)) while the DB column is gone — the schema and the database disagree"
  WRITERS=$(rg -l 'internalFraming' server/src --glob '!*.test.ts' 2>/dev/null | grep -v 'prompts' | tr '\n' ' ')
  [ -z "$WRITERS" ] \
    && warden_pass "IF-D2: no server code writes the retired column" \
    || warden_fail "IF-D2: server code still references the dropped column: $WRITERS"
else
  # ── WIRED branch ────────────────────────────────────────────────────────
  warden_pass "IF-DISPOSITION: the column was KEPT — holding it to the 'wired into generation' half of the ruling"

  # IF-W1: creation populates it (the issue's fourth bullet — both paths, not
  # just bootstrap). The type made in step 3 came through the HTTP route.
  FRAMING=$(warden_psql_one "SELECT coalesce(internal_framing,'') FROM wiki_types WHERE slug = '$TYPE_SLUG'")
  [ -n "${FRAMING// }" ] \
    && warden_pass "IF-W1: the HTTP creation path populated internal_framing for the custom type" \
    || warden_fail "IF-W1: internal_framing is empty on a type created through the HTTP route — bootstrap still sets it and the create paths still do not (#367 bullet 4)"

  # IF-W2: BEHAVIOURAL — the framing text visibly influences generation. A
  # column that is read but has no effect on the prompt is the same defect
  # wearing a read. The directive is a token the model must emit; the wiki is
  # a fresh one so nothing else can explain its appearance.
  TOKEN="WARDEN367FRAMING${RUN_TS}"
  PUT_CODE=$(warden_authed_curl -o /dev/null -w '%{http_code}' -X PUT \
    -H 'Content-Type: application/json' \
    -d "$(jq -cn --arg y "internal_framing: |
  Write this document as an incident reconstruction. You must include the exact
  token ${TOKEN} verbatim, once, on its own line at the very end of the body.
default_structure: |
  Use this exact structure:
  # Incident: {{title}}
  ## Timeline
  What happened, in order.
  ## Impact
  What it cost, measured.
  ## Remediation
  What was changed afterwards.
" '{promptYaml:$y}')" \
    "$SERVER_BASE/preferences/wiki-types/$TYPE_SLUG")
  case "$PUT_CODE" in
    200|204) warden_pass "IF-W2a: stored a framing directive on the custom type via PUT (HTTP $PUT_CODE)" ;;
    *) warden_fail "IF-W2a: PUT /preferences/wiki-types/$TYPE_SLUG returned $PUT_CODE — could not stage the framing-influence probe" ;;
  esac

  seed_wiki /tmp/warden-367-framing "$TYPE_SLUG" "Warden 367 Framing Probe ${RUN_TS}" \
    "The framing probe incident: the intake valve at the east reservoir jammed open on 2 April and overflowed for three hours before anyone noticed." \
    "Impact of the 2 April intake valve failure: an estimated 400 cubic metres lost, no service interruption, and a damaged access road." \
    "Remediation for the 2 April valve failure: a position sensor was fitted on 11 April and the overflow alarm was rerouted to the on-call phone."
  FR_SEEDED=$?
  FR_BODY=/tmp/warden-367-framing-body.md
  if [ "$FR_SEEDED" = "0" ] && [ -s "$FR_BODY" ]; then
    if grep -qF "$TOKEN" "$FR_BODY"; then
      warden_pass "IF-W2b: the stored framing visibly influenced generation — the directive's token reached the body"
    else
      echo "--- framing probe body ---" >&2; cat "$FR_BODY" >&2
      warden_fail "IF-W2b: the framing directive had no observable effect on the generated body — internal_framing is kept but still does not reach the model, which is the state the issue rules out"
    fi
    grep -qF '## Timeline' "$FR_BODY" \
      && warden_pass "IF-W3: the type's own default_structure drove the body's headings" \
      || warden_skip "IF-W3 structure-following" "the probe's '## Timeline' heading did not land — real-model structure adherence varies; IF-W2b is the load-bearing half"
  else
    warden_fail "IF-W2b: the framing probe never Seeded — cannot judge whether internal_framing influences generation"
  fi
fi

Shape (a note for the next author)

What is load-bearing and what is not. Exactly four assertions decide whether #367 is fixed: CB-1 (no POV section signatures on a custom type's body), CB-3a/b (citation markers present and resolving), CB-4a (the structured infobox contract survived), and RG-1 (POV still generates as POV). Everything in Step 2 is corroborating — it localises a red to the loader seam and reads in a second, but a green Step 2 proves nothing about generated bytes, which is where the issue's entire complaint lives. CB-0, CB-2, RG-2 and IF-W3 are prose- or design-sensitive and are built to skip with the transcript rather than fail.

Why the negative is the gate and not a positive. "The body follows the type's own descriptors" cannot be asserted crisply against a real model — an incident report can be a good incident report in a hundred wordings. "The body contains ## The Position" can: that string comes from pov.yaml's default_structure, under a literal Use this exact structure: instruction, and there is no honest reason for it to appear on an incident report. The negative is cheap, stable across model versions, and fails loudly for exactly the defect the issue reports.

Why tier: volatile. Real model, real spend, real prose variance — 20-v11-batch-issue-328's precedent. volatile outranks needs-model, needs-server and needs-postgres in .warden/TIERS.md's primary-tier order, so those three go in requires:.

Why the fixture goes through POST /signals/log. It is the same seam the web capture rail uses, it creates the signal and attaches it to the wiki by slug in one call, and it gives the Seed real content to cite — which CB-3b then checks the markers against. Creating a wiki with no signals would put this plan on #328's ungrounded-authoring path instead, and the Seed would either refuse or produce something CB-3 could not judge.

Cost. Three Seeds on the happy path (custom, pov, framing probe), four if the internal_framing branch is wired. Budget ~2-4 minutes of wall clock and the corresponding OpenRouter spend per run.

What is real-model-dependent and could need a follow-up if it goes red:

Stays manual / not asserted here:

Appendix — complementary unit-test spec (NOT run by warden)

These belong in the repo's own vitest suites, alongside the code they cover. They are hermetic and fast, and they cover exactly what this plan cannot: the render, before a model ever sees it. Warden proves the outcome; these prove the mechanism, and catch the regression in CI without spend.

A. Neutral-base render snapshotspackages/shared/src/__tests__/prompts/wiki-generation-base.test.ts (new)

B. Fallback-resolution unit tests — RED FIRST against today's pov.yaml same file. Each of these must be demonstrated failing on canary before the fix lands, then passing after:

C. Locked-field integrity — extend server/src/core/synthesis/regen.test.ts's existing 'regenerateWiki — override hierarchy integration' describe:

D. Shipped-type snapshot stability — extend packages/shared/src/__tests__/prompts/wiki-generation-specs.test.ts:

E. Creation-path compositionserver/src/modules/preferences/wiki-types.routes.test.ts and the MCP handler test: