Spaces:
Sleeping
Sleeping
| # DEVLOG — build notes, decisions & challenges | |
| A running record of how the triage copilot was built, the decisions made, and the challenges hit | |
| along the way. Written to feed the application's video walkthrough and writeup (the JD grades "clear | |
| communication of reasoning" and "trade-offs considered"). | |
| --- | |
| ## Working method | |
| Everything is backed by an **offline-first smoke harness** (`scripts/smoke_test.py`) that runs in | |
| four layers and skips what it can't reach (heavy deps / no API key) rather than failing: | |
| 1. data layer (pydantic only) · 2. index + retrieval (chromadb) · 3. agent wiring (Pydantic AI | |
| `TestModel`, no API call) · 4. live triage (needs a key). | |
| This let almost everything be verified **for free** — no API spend, no Ollama required for the | |
| deterministic checks. Live calls are reserved for explicit confirmation. | |
| --- | |
| ## Phase 2 — agent + tools (summary + challenges) | |
| Built `schemas.py` (typed `TriageDecision`), `campaigns.py` (loads a campaign and **strips every | |
| `_`-prefixed key** so private `_design_note`/`_expected` never reach the model), `policy.py` (parses | |
| `policy.md` into 26 citable rules), `tools.py` (RAG + mock sanctions + a deterministic risk scanner), | |
| and `agent.py` (Pydantic AI agent → structured `TriageDecision`). | |
| **Challenges hit and resolved:** | |
| - **`RunContext` forward-ref crash.** Tools were annotated `RunContext[Deps]`, but `RunContext` was | |
| imported *inside* `build_agent()`. Pydantic AI resolves a tool's type hints against the **module | |
| globals** at registration time, so the annotation failed with `NameError: RunContext`. Fix: import | |
| `Agent`/`RunContext` at module top. | |
| - **ChromaDB client-lifecycle flakiness (the nastiest).** `store._client()` created a **new** | |
| `PersistentClient` on every call, and the agent makes many tool calls per triage. Re-instantiating | |
| Chroma's client against the same path corrupts its shared system client, surfacing as *intermittent* | |
| `Could not connect to tenant default_tenant` / `'RustBindingsAPI' object has no attribute 'bindings'` | |
| errors — passed once, failed the next run. Fix: cache the client + collections as per-process | |
| singletons (`lru_cache`); added a 12× repeat guard to the smoke test so it can't regress. | |
| - **Windows console encoding (cp1252).** A summary `print` used `→`, which isn't in cp1252 → | |
| `UnicodeEncodeError` (em-dashes happen to be in cp1252, so they slipped by; the arrow didn't). Fix: | |
| ASCII in CLI prints + a UTF-8 `stdout.reconfigure`. Also silenced HF/transformers progress bars that | |
| were corrupting piped output and tripping `grep`'s binary detection. | |
| - **Index citation duplication.** The policy parser already keeps the rule ID inside `rule.text`, but | |
| `build_index` prepended it again (`ELIG-2 — ELIG-2 — …`). Fix: index `rule.text` directly. | |
| ## Phase 2.5 — free local LLM toggle | |
| Added a provider seam so triage can run on a **free local Ollama** model for dev, Claude for the demo. | |
| `agent._resolve_model()` reads `CONFIG.llm_provider` (anthropic default; `--provider ollama` per-run). | |
| **Challenges:** pydantic-ai's `OllamaProvider` imports `openai` (not pulled by the `[anthropic]` | |
| extra) → added it. And `OllamaProvider` does **not** auto-append `/v1` to the base URL — it passes it | |
| straight to the OpenAI client, so the code appends `/v1` to `OLLAMA_HOST`. Verified end-to-end on | |
| `qwen2.5:7b-instruct`: camp-005 → REJECT (cited PROH-3), camp-017 → APPROVE. Quality is below Claude | |
| (occasionally an empty rationale), so local is the dev loop and the demo stays Claude. | |
| --- | |
| ## Phase 3 — moderator review queue (this phase) | |
| Goal: make the **human/AI boundary** visible. The AI recommends; a human decides and can override; | |
| every decision is logged. No auto-decide path. | |
| ### Steps taken | |
| 1. **`src/audit.py`** — append-only JSON-Lines decision log: `append_decision()` (auto-stamps a UTC | |
| timestamp), `load_decisions()` (tolerates a missing/half-written file), `decided_campaign_ids()` | |
| (latest decision per campaign, to mark the queue). Flat file chosen over a DB: right-sized for the | |
| demo, trivially greppable, easy to show on video, clean seam to swap later. | |
| 2. **`src/policy.py`** — added `policy_index()` / `get_rule(rule_id)` (reusing `parse_policy_rules`) | |
| so the UI can expand a cited rule to its full policy text. | |
| 3. **`app.py`** — full rewrite from the placeholder into the queue: sidebar (provider toggle + index | |
| counts + audit history), a campaign selector marking decided items, a left/right split (campaign | | |
| AI recommendation), the decision card, and the human decision controls. | |
| 4. **`scripts/smoke_test.py`** — added deterministic checks for the audit round-trip and the rule | |
| lookup. Now **10 pass / 1 skip** (live triage) with no key. | |
| 5. Verified: app parses, imports, both render functions execute (incl. nested columns + expanders), | |
| and a real headless `streamlit run` serves (`/_stcore/health` → `ok`). | |
| ### Key decisions (and why) | |
| - **"What the AI couldn't verify" without a schema change.** The card wants a "needs verification" | |
| section, but `TriageDecision` has no such field. Rather than change the schema (which ripples into | |
| the agent prompt + eval + local-model reliability), it maps to the existing | |
| `questions_for_submitter`, falling back to a low-confidence note. Reuse over new surface area. | |
| - **Triage cached in `st.session_state`, not `st.cache_data`.** Streamlit reruns the whole script on | |
| every interaction; without caching, clicking any button would re-invoke (and re-bill) the agent. | |
| Storing the `TriageDecision` per campaign id in session state means **one triage per campaign** until | |
| an explicit "Re-run". Chose session state over `@st.cache_data` to avoid hashing a pydantic model | |
| and a (possibly network-bound) model object. | |
| - **Override gating is the governance story.** The human decision maps to a recommendation | |
| (Approve↔APPROVE, Reject↔REJECT, Request-info↔ESCALATE). If it **contradicts** the AI, a written | |
| reason is **required** before the decision logs — the audit record carries `is_override` + `reason`. | |
| This is the most direct expression of "where AI should not replace human judgment." | |
| - **Provider toggle in the sidebar.** Lets the whole queue be exercised for free on Ollama during dev, | |
| then flipped to Claude live for the recording, without editing `.env`. | |
| ### Challenges hit | |
| - **Streamlit rerun model.** Buttons trigger a top-to-bottom rerun, so widget *read order* matters: | |
| the reason `text_area` is defined **before** the decision buttons so its current value is available | |
| when a button handler runs in the same pass. Widgets are keyed per campaign id (`reason_{cid}`, | |
| `ap_{cid}`, …) so switching campaigns doesn't bleed state. | |
| - **Nested columns.** The decision controls put `st.columns(3)` inside the right-hand column (one | |
| level of nesting). Older Streamlit forbade this; 1.40 allows a single level. Verified by executing | |
| the render functions directly in bare mode (no exception) before trusting it in the live app. | |
| - **Verifying a UI without clicking.** Button clicks can't be automated here. Worked around it by (a) | |
| bare-importing `app.py` (runs the whole top-level script), (b) calling `render_decision` / | |
| `render_human_controls` directly with a sample `TriageDecision` to exercise the dynamic paths, (c) | |
| unit-testing the audit round-trip (incl. an override record), and (d) a real headless server boot. | |
| The only step left to a human is the final interactive click-through. | |
| - **Not spending the user's credits during tests.** The full smoke test's live layer reads | |
| `ANTHROPIC_API_KEY` from `.env`; ran it with the key blanked (`ANTHROPIC_API_KEY=`) so the live | |
| check **skips** while everything else (10 checks) runs. | |
| ### Verification status | |
| `scripts/smoke_test.py` → **10 pass, 1 skip** (live triage). App boots headless and serves. End-to-end | |
| data flow (triage → audit record incl. override) covered by the sum of the live-Ollama triage runs and | |
| the audit round-trip test. Remaining: human interactive click-through, then Phase 4 (eval) + Phase 5 | |
| (deploy). | |
| --- | |
| ## Phase 3.5 — deterministic policy gate (the "not-a-wrapper" layer) | |
| **Why this phase exists.** A review of the pipeline exposed the real weakness: despite the RAG, | |
| the risk scanner, the sanctions matcher and the typed schemas, **nothing in code constrained the | |
| final decision** — `triage()` returned the model's `result.output` verbatim. Every deterministic | |
| signal was fed to the model as *advice*; the policy invariants ("sanctions → escalate", "REJECT | |
| needs a cited hard rule", "low confidence on money → escalate", "injection → escalate") lived only | |
| as prose in the system prompt. So the adjudication was 100% the model's free judgment — which is | |
| both why it *felt* like a wrapper and why a weak local model (Ollama) and Claude diverged so much: | |
| swap the brain, swap the behavior, because the brain was the whole system. | |
| **The fix — `src/gate.py`.** A deterministic gate sits between the model's `TriageDecision` and the | |
| final output. It recomputes the deterministic facts itself (it does **not** trust the model's | |
| self-report) — `scan_risk_signals`, `check_sanctions`, `valid_rule_ids` — and reconciles them | |
| against the model's recommendation. The envelope has exactly one rule: **the gate may only route | |
| toward the human (→ ESCALATE).** It never produces a more confident decision than the model | |
| (never APPROVE→REJECT, never ESCALATE→APPROVE, never invents a REJECT) and never overrides the | |
| human. The AI component can only ever defer harder to a person, never seize more authority. | |
| Enforced invariants (each override cites the rule it enforces): sanctions match → ESCALATE | |
| (`COMP-1`); prompt injection → ESCALATE + the manipulation flag is corrected even if the model | |
| missed it, because the deterministic scanner is authoritative (`DEC-6`); a high-severity risk | |
| signal blocks an APPROVE (`DEC-3`, or `COMP-2` for a high-value goal); low-confidence APPROVE → | |
| ESCALATE (`DEC-5`); and a REJECT without a *valid hard* citation → ESCALATE (`DEC-2`). | |
| **Calibration that matters.** Only **high**-severity signals block an APPROVE. That keeps the | |
| showcase intact: camp-017 (debt principal) fires only `interest_mentioned`/`unverified_organizer` | |
| (both low) → APPROVE survives, while camp-005 (riba investment) fires `investment_return` (high) → | |
| an erroneous APPROVE would be caught. A REJECT resting on a real hard `PROH-3` citation stands. | |
| **Payoff.** The safety-critical behavior no longer depends on model IQ — Ollama and Claude | |
| converge on the cases that matter — and the gate's invariants are pure-Python, zero-API, so they | |
| double as the deterministic seed of the Phase 4 eval. The UI shows a gate banner whenever it | |
| adjusts a recommendation, and the audit log now records `ai_llm_recommendation` (raw) + | |
| `gate_overrides` alongside the final decision. | |
| ### Challenges hit | |
| - **Model nondeterminism on the live showcase.** Haiku sometimes returns a REJECT for camp-005 | |
| without a properly-typed hard citation; the gate then (correctly) routes it to ESCALATE per | |
| DEC-2. The live smoke assertion was relaxed from `== "REJECT"` to the model-independent invariant | |
| "**never APPROVE**" (REJECT *or* ESCALATE), since the deterministic gate test already proves the | |
| transition logic exactly. | |
| - **Schema confusion crashed whole runs.** Haiku intermittently emitted `severity="hard"` on a | |
| `RiskSignal` (confusing it with `RuleViolation`'s scale), exhausting Pydantic AI's retries and | |
| aborting the triage. Added a lenient `field_validator` on `RiskSignal.severity` that maps the | |
| common confusions (`hard→high`, `soft→medium`, …) — same "don't depend on the model being | |
| perfect" philosophy as the gate. (Separately observed, still open: Haiku occasionally passes | |
| phantom args to the zero-arg `check_sanctions` tool, and the documented intermittent Chroma | |
| RustBindings flake on Windows — both pre-existing, neither caused by the gate.) | |
| ### Verification status | |
| `scripts/smoke_test.py` → **12 pass** under the venv (incl. a new 7-assertion deterministic gate | |
| test, the TestModel wiring through the gate, and live camp-005). Live CLI confirmed: camp-005 → | |
| REJECT preserved (gate agrees, no override); camp-009 → ESCALATE. | |
| --- | |
| ## Phase 3.6 — React + FastAPI moderator console (replacing Streamlit as the shipped UI) | |
| **Why.** Streamlit looks like a script UI; the demo video wanted something that reads as a real ops | |
| console. The triage/policy/audit code was already UI-agnostic (`triage()` returns a typed | |
| `GatedDecision`; audit/campaigns/policy are pure functions), so the move cost no changes to the AI | |
| layer — only a thin HTTP wrapper + a frontend. | |
| **Backend — `api.py` (FastAPI).** Wraps `src/` as `/api/*`. Pydantic models serialize for free. | |
| Two pieces of logic live here rather than in the client, on purpose: | |
| - **Override governance is server-enforced.** `POST /api/decisions` looks up the cached triage, | |
| computes `is_override`, and **returns 400 if a contradiction has no written reason**. A UI cannot | |
| bypass the human/AI boundary — a stronger story than UI-only validation. | |
| - **Per-campaign triage cache** (last-run-wins) so reruns/interactions don't re-bill; `force=true` | |
| re-runs. The endpoint also enriches each cited `rule_violation`/override with its policy rule text | |
| so the frontend needs no extra round-trips. | |
| In production FastAPI also serves the built SPA (`StaticFiles` mounted at `/` *after* the API routes), | |
| so the whole app is one origin in one container. | |
| **Frontend — `frontend/` (Vite + React + TS + Tailwind).** Deliberately lean for low-risk/fast: no | |
| router, no state library — `useState`/`useEffect` + a small typed `fetch` wrapper (`api.ts`), and | |
| hand-written TS mirrors of the schema (`types.ts`) instead of an OpenAPI codegen step. Single-screen | |
| console: queue → campaign detail → triage → human controls + audit drawer. The **gate banner** is the | |
| visual headline (indigo, "Policy gate adjusted {llm} → {final}" + cited rules). Tailwind class maps | |
| use full literal strings (`lib.ts`) so the content scanner keeps them. | |
| **Deploy — Docker on HF Spaces.** Multi-stage `Dockerfile` (node builds the SPA → python serves). | |
| The Chroma index is **rebuilt at image-build time** (`python -m scripts.build_index`, local | |
| embeddings, no key) rather than committed, so there's no binary blob in git and no ingestion step on | |
| the Space. `data/` made world-writable for the non-root HF user (audit log + Chroma sqlite). README | |
| metadata flipped to `sdk: docker`, port 7860. | |
| ### Challenges / decisions | |
| - **`npm --prefix` on Windows** read the repo-root `package.json` (which doesn't exist) instead of | |
| `frontend/` — switched to running from the `frontend/` dir. | |
| - **Build script is `vite build` (no `tsc`)** so a stray type nit can't block the production build; | |
| types still guide the editor. | |
| - **Kept `app.py`.** The Streamlit queue stays as a zero-dependency local fallback; the shipped UI is | |
| React, but if Node/Docker is unavailable the demo still runs. | |
| ### Verification status | |
| `python -m scripts.test_api` → **7/7** offline (incl. override-governance 400 + rule-text enrichment). | |
| `npm run build` clean (≈165 kB JS gzip 52 kB). A real uvicorn server serves `/`→200 (html), | |
| `/assets/*`→200, `/api/*`→200 from one origin; live `/api/triage` camp-017 → APPROVE (gate agrees). | |
| Remaining: `docker build` (daemon was off locally) + the in-browser click-through. | |
| ## Phase 3.7 — policy reference drawer + clickable citations | |
| **Why.** The decision card cites bare rule IDs (`COMP-1`, `PROH-3`). The five prefixes | |
| (ELIG / PROH / COMP / CONT / DEC) are internal jargon, and the only way to read a rule was to triage | |
| a campaign and expand that one citation — no way to learn the taxonomy or browse the policy. A | |
| moderator shouldn't have to guess what a code means. | |
| **What.** A **Policy** button in the header opens a side drawer (mirrors the audit-history drawer) | |
| listing all 26 rules grouped by section, each section led by a plain-English name + one-line blurb. | |
| Every cited rule ID — in both the rule list and the gate banner — is now a **clickable chip** | |
| (`RuleIdChip`) that opens the drawer scrolled to and highlighting that exact rule (`focusRule` + | |
| `scrollIntoView` + a teal ring). | |
| **How / decisions.** | |
| - Backend stayed thin: `src/policy.py` gained `SECTION_META` (the prefix → name/blurb map — the | |
| headings in `policy.md` only carry names, so the blurbs live in code as the single source) and | |
| `policy_sections()`, which groups `parse_policy_rules()` by `rule_id.split("-")[0]`. One new route | |
| `GET /api/policy`. No AI/triage/gate code touched; zero new LLM spend. | |
| - `RuleRow` was a single `<button>`; making the rule ID separately clickable would have nested a | |
| button in a button, so the row was split into a chevron toggle + the `RuleIdChip` + an evidence | |
| button. Same expand behavior, valid markup. | |
| - Reused the `AuditHistory` `aside w-80 border-l` pattern wholesale; the two drawers coexist. | |
| **Verification.** `python -m scripts.test_api` → **8/8** (new `/api/policy`: 5 sections in canonical | |
| order, every section named + described, rule count == `valid_rule_ids()`). `npm run build` clean | |
| (typecheck passes). `scripts.smoke_test` → 12/12 (one transient live-Haiku *output*-retry flake on a | |
| first run, green on re-run — orthogonal to this change). Remaining: in-browser click-through. | |
| --- | |
| ## Phase 4 — evaluation harness + CI | |
| **Why.** The JD asks for measurable quality that runs automatically. We had the *seed* of it (the | |
| policy gate's invariants were already pure-Python and unit-tested) but no harness that scored the | |
| agent against ground truth, and nothing in CI. This phase turns the 18 labelled campaigns into a | |
| test set and makes the safety properties a build gate. | |
| **Three layers** (`eval/run_eval.py`, a full rewrite — the old file was the retired YouTube-RAG | |
| eval that imported `src.rag`): | |
| 1. **Deterministic — the model-free safety gate (free, blocks CI).** No LLM, no index, no key. | |
| Checks: (a) the **privacy boundary** — `load_campaign` lets no `_`-prefixed key reach the agent | |
| for any campaign; (b) **ground-truth citation validity** — every rule ID in every `_expected` | |
| block is a real ID in `policy.md`; (c) the **policy-gate envelope** — feed *synthetic* | |
| `TriageDecision`s through the real `apply_policy_gate` and assert the invariants from both | |
| directions: a sanctions hit (camp-009) can't be approved away (`COMP-1`), injection (camp-015) | |
| escalates *and* the manipulation flag is corrected even when the model set it False (`DEC-6`), | |
| low-confidence approve → human (`DEC-5`), a reject with no hard citation → escalate (`DEC-2`); | |
| **and** the envelope never over-fires — a clean approve survives, a well-founded `PROH-2` reject | |
| passes through untouched, and an ESCALATE is terminal. Ten checks; exit non-zero on any failure. | |
| 2. **Triage scoring (needs a key).** Runs the real agent (`triage()`, gate included) over the test | |
| set and computes **recommendation accuracy**, **escalation recall** (overall + a *safety-critical* | |
| subset — the ESCALATE cases citing `COMP-*`/`DEC-6`/`ELIG-4` — which is the metric the video | |
| quotes and CI `--strict` gates at 100%), **reject precision + false-reject count** (the dangerous | |
| error is rejecting a legitimate campaign, so false rejects are surfaced by id), and **citation | |
| validity** of the model's *own* citations. Each case is wrapped so one flaky run (the known | |
| Windows Chroma flake) records an `ERROR` row instead of losing the whole eval. | |
| 3. **LLM-as-judge (`--judge`).** Scores rationale **faithfulness** (is every claim supported by the | |
| cited rule text + campaign facts?) and **calibration** (does it escalate under genuine | |
| uncertainty rather than guess?) 1–5 on a subset, via the legacy `src/llm` provider Protocol. | |
| **Test set.** `eval/build_testset.py` collects each campaign's `_expected` into a committed | |
| `eval/testset.json` (18 cases); `--check` mode fails CI if the file drifts from the campaigns, so | |
| the ground truth can't silently rot. The legacy `testset.example.json` (YouTube format) was removed. | |
| **CI — `.github/workflows/eval.yml`.** On every push/PR: install deps, assert the test set is | |
| current, run the **deterministic gate (blocking, free)**. A second step runs `build_index` + the | |
| LLM-judge on a 5-case subset **only if an `ANTHROPIC_API_KEY` secret is present** (`if: env.… != ''`), | |
| so forks and an unconfigured repo still go green, and the live model never gates the build or | |
| surprises us with spend. Results upload as an artifact. | |
| ### Key decisions (and why) | |
| - **"Deterministic" = objectively-scored, not necessarily model-free — but the *CI gate* is | |
| model-free.** The triage metrics are deterministic *scoring* of model output; producing that | |
| output needs the LLM. So CI's blocking gate is the layer that needs no model (boundary + citation | |
| validity + the gate envelope). That keeps the gate **free, fast, and stable** — it can't flake on | |
| model nondeterminism — which is exactly the property a safety gate should have. | |
| - **Probe the gate from both sides.** It's not enough to prove the gate escalates the dangerous | |
| cases; an over-eager gate that escalated *everything* would also pass that. The envelope checks | |
| include "clean approve survives" and "founded reject survives" so the test pins the gate to *only* | |
| routing toward the human when a rule actually fires — the same property that keeps the camp-017 | |
| showcase APPROVE alive. | |
| - **Safety-critical recall is the gate metric, not raw accuracy.** A weak model will get the | |
| *category* of a clean approve wrong sometimes; that's tolerable. Missing a sanctions/injection | |
| escalation is not. `--strict` fails only on the safety subset, invalid citations, or a false | |
| reject — never on a benign accuracy miss. | |
| ### Verification status | |
| `eval.run_eval --deterministic-only` → **10/10 PASS**, exit 0, offline (no key, no index, no | |
| `pydantic_ai` import — the agent import is lazy so the free gate stays light). `build_testset | |
| --check` green. Full triage layer exercised end-to-end on the **free local model** (`--provider | |
| ollama`, zero API spend) to confirm the scoring/metrics/judge code paths and the gate's robustness | |
| on a weak brain. `scripts.smoke_test` still 12/12. **Remaining:** a full Anthropic run for the | |
| demo's headline numbers, and adding the `ANTHROPIC_API_KEY` secret in GitHub so the CI judge step | |
| activates. | |
| --- | |
| ## Phase 4.1 — Gate hardening (2026-06-06) | |
| The Phase-4 eval did its job: a weak-model (Ollama) run, plus the user's live console click-through, | |
| surfaced two genuine holes in the policy gate. Closed both in `src/gate.py` without widening the | |
| gate's envelope (it still only ever routes *toward* the human). | |
| ### Finding #1 — ELIG-4 not enforced on APPROVE | |
| The gate blocked an APPROVE only on *high*-severity risk signals and on low confidence. But the | |
| fund-use-breakdown signal `large_goal_no_breakdown_check` is *medium* severity, so a `>$10k` APPROVE | |
| with no breakdown (camp-011/012) slipped straight through. **Fix:** a `_BREAKDOWN_SIGNAL` trigger in | |
| the APPROVE block → ESCALATE citing **ELIG-4**. The scanner can't confirm a breakdown is actually | |
| present (the signal fires on goal amount alone), so *every* large-goal approve defers to a human to | |
| check one — calibrated humility, not rejection. The `>=$50k` band is already covered by | |
| `high_value_goal`→COMP-2, so the two bands are contiguous. | |
| ### Finding #2 — gate trusted a hard citation's *existence*, not its correctness | |
| A weak model fabricated a hard category to force a REJECT (camp-018 invented COMP-3; camp-011 in the | |
| user's live run invented **PROH-2/PROH-4** on a vague-beneficiary campaign), and the old | |
| `_has_valid_hard_citation` preserved the REJECT because the cited ID *was* a real hard rule. **Decided | |
| policy + fix:** `_has_confirmed_hard_citation` requires a hard citation to be **corroborated by the | |
| deterministic scanner** via `_HARD_RULE_SIGNALS` (PROH-2→weapons, PROH-3→investment_return, | |
| PROH-4→prize_draw, PROH-7→investment_return, COMP-3→off_platform_payment). All four genuine-reject | |
| campaigns carry their corroborating signal, so real rejects survive; an uncorroborated hard citation | |
| escalates (DEC-2). Content rules with no deterministic detector (PROH-1 illegal, PROH-5 hate, PROH-6 | |
| adult) are not confirmable, so a citation alone no longer holds the REJECT — it escalates rather than | |
| trust the model's word. | |
| **Why this is still inside the envelope.** The gate does *not* re-adjudicate whether the cited rule | |
| substantively applies (that would be the gate overruling the model's reasoning). It only demands | |
| *independent deterministic evidence* before letting the AI's REJECT stand un-escalated. Where the | |
| evidence is absent, it does the one thing the gate is always allowed to do: hand the case to a human. | |
| ### Tests | |
| - `scripts/smoke_test.py::t_policy_gate` +2 assertions (uncorroborated PROH-2 reject → ESCALATE; | |
| large-goal APPROVE → ELIG-4). | |
| - `eval/run_eval.py` deterministic layer **10 → 12 checks**: `gate_founded_reject_survives` moved | |
| onto camp-008 (corroborated PROH-2); added `gate_uncorroborated_reject_escalates` (camp-011) and | |
| `gate_large_goal_approve_escalates` (camp-012); `gate_clean_approve_survives` moved off camp-001 | |
| ($14k — now correctly ELIG-4-blocked) onto the camp-017 showcase ($5.5k). | |
| ### Verification status | |
| `eval.run_eval --deterministic-only` → **12/12 PASS** offline. `scripts.smoke_test` → **12/12** under | |
| the venv, incl. **live camp-005** Claude triage exercising the new corroboration path. | |
| `scripts.test_api` → **8/8**. `build_testset --check` clean. camp-017 debt-principal showcase APPROVE | |
| preserved. **Remaining:** commit + push (CI re-run), then a full `--judge` Anthropic eval for the | |
| demo's headline numbers. | |