Files
root f5175f1ce0 Sync docs, audit artifacts, project notes, and VerdictTank proposal docs
- audit/phase-one + phase-two: security audit briefs, findings, credential-rotation plan, Docker-USER hardening scripts, rollback refs
- disaster-recovery/restore-test-log.md + backup-dr-audit-2026-08-10.md
- clients/ (modelortho SEO audit, ai-biz-dev competitive landscape), notes/ (tiktok strategy)
- projects/: front-desk-voice-agent, seo-visibility-checker product plan, hotnow-savannah HTML, resend-transactional-email, backup-dashboard-enhancements, code-review-graph, seo-ci-architecture
- proposals/verdicttank/: architecture v4.0, methodology, judge-pool review, consolidation reasoning, cross-check review
- docs/super-search/firecrawl-provider-strategy.md
- updates: CHANGELOG, model-chain, projects-master-readme, intelsight.io
- .gitignore: exclude nested standalone repos (seo-tool, venturebuilt)
2026-08-26 02:27:28 -04:00

1116 lines
69 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VerdictTank v4.0 — Technical Architecture</title>
<style>
:root{
--bg:#0b0e14;--panel:#131824;--panel2:#1a2030;--ink:#e6edf3;--muted:#8b97a8;
--line:#232b3a;--accent:#4da3ff;--accent2:#7ee787;--warn:#f0a45c;--bad:#ff6b6b;
--code:#0d1117;--codeink:#c9d1d9;--pass:#2ea043;--below:#d29922;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
.wrap{max-width:1180px;margin:0 auto;padding:0 24px}
header.hero{background:linear-gradient(135deg,#0b0e14,#131824 60%,#0f1a2e);
border-bottom:1px solid var(--line);padding:48px 0 36px}
.badge{display:inline-block;font-size:12px;letter-spacing:.14em;text-transform:uppercase;
color:var(--accent);border:1px solid var(--accent);border-radius:999px;padding:4px 12px;margin-bottom:16px}
h1{font-size:34px;margin:6px 0 8px;letter-spacing:-.5px}
.sub{color:var(--muted);font-size:16px;max-width:760px}
.meta{margin-top:18px;display:flex;flex-wrap:wrap;gap:10px}
.meta span{font-size:12px;background:var(--panel2);border:1px solid var(--line);
border-radius:6px;padding:5px 10px;color:var(--muted)}
.meta b{color:var(--ink)}
nav.toc{position:sticky;top:0;z-index:20;background:rgba(11,14,20,.92);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:10px 0}
nav.toc ol{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px;font-size:12.5px}
nav.toc a{color:var(--muted);text-decoration:none;padding:4px 9px;border-radius:6px;border:1px solid transparent}
nav.toc a:hover{color:var(--ink);border-color:var(--line);background:var(--panel)}
section{padding:40px 0;border-bottom:1px solid var(--line)}
h2{font-size:24px;margin:0 0 6px;letter-spacing:-.3px}
h2 .n{color:var(--accent);font-variant-numeric:tabular-nums;margin-right:10px}
h3{font-size:17px;margin:26px 0 8px;color:var(--accent2)}
h4{font-size:14px;margin:18px 0 6px;color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
p{margin:10px 0}
.lead{color:var(--muted);font-size:15.5px;margin-bottom:8px}
ul,ol{margin:10px 0;padding-left:22px}
li{margin:5px 0}
code{background:var(--code);color:var(--codeink);padding:2px 6px;border-radius:4px;
font:13px/1.5 "SF Mono",ui-monospace,Menlo,Consolas,monospace}
pre{background:var(--code);color:var(--codeink);border:1px solid var(--line);border-radius:10px;
padding:16px;overflow:auto;font:12.5px/1.55 "SF Mono",ui-monospace,Menlo,Consolas,monospace;margin:14px 0}
pre.ascii{line-height:1.35;font-size:12px;color:#9fb3c8}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--ink);font-weight:600}
td{background:var(--panel);color:var(--ink)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin:14px 0}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
@media(max-width:820px){.grid2,.grid3{grid-template-columns:1fr}}
.tag{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;margin:2px 4px 2px 0}
.t-get{background:#132a1c;color:#7ee787;border:1px solid #1f6f3f}
.t-post{background:#12233a;color:#4da3ff;border:1px solid #1f4f7f}
.t-put{background:#2a2312;color:#f0a45c;border:1px solid #7f5f1f}
.t-del{background:#2a1216;color:#ff6b6b;border:1px solid #7f1f2a}
.pill-pass{background:#0f2a17;color:#2ea043;border:1px solid #2ea043;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:600}
.pill-below{background:#2a2410;color:#d29922;border:1px solid #d29922;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:600}
.note{border-left:3px solid var(--accent);background:var(--panel);padding:10px 14px;margin:14px 0;border-radius:0 8px 8px 0}
.warnbox{border-left:3px solid var(--warn);background:var(--panel);padding:10px 14px;margin:14px 0;border-radius:0 8px 8px 0}
.legacy{border-left:3px solid var(--bad);background:var(--panel);padding:10px 14px;margin:14px 0;border-radius:0 8px 8px 0}
.mono{font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace}
.kv{font-size:13px}.kv b{color:var(--accent2)}
footer{padding:36px 0;color:var(--muted);font-size:13px}
hr{border:0;border-top:1px solid var(--line);margin:22px 0}
.eplist{list-style:none;padding:0}
.eplist li{border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin:6px 0;background:var(--panel)}
.small{font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<header class="hero">
<div class="wrap">
<span class="badge">Technical Architecture · v4.0</span>
<h1>VerdictTank — Multimodel Proposal Review Platform</h1>
<p class="sub">Complete implementation reference for the VerdictTank consensus-scoring engine.
Any software engineer can build the system from this document alone: pipeline topology, scoring
schemas, fallback chains, data model DDL, API surface, security controls, and deployment topology.</p>
<div class="meta">
<span>Version <b>v4.0</b></span>
<span>Domain <b>verdicttank.com</b></span>
<span>Status <b>Implementation-Ready</b></span>
<span>Origin <b>netcup RS 4000 (app3)</b></span>
<span>Judges <b>3 via LiteLLM</b></span>
<span>Verdict <b>Binary PASS / BELOW</b></span>
</div>
</div>
</header>
<nav class="toc"><div class="wrap"><ol>
<li><a href="#overview">1 · System Overview</a></li>
<li><a href="#pipeline">2 · Scoring Pipeline</a></li>
<li><a href="#fixit">3 · Fix-It Engine</a></li>
<li><a href="#dataflow">4 · Data Flow</a></li>
<li><a href="#api">5 · API Surface</a></li>
<li><a href="#datamodel">6 · Data Model</a></li>
<li><a href="#security">7 · Security</a></li>
<li><a href="#wlgate">8 · White-Label Gate</a></li>
<li><a href="#deploy">9 · Deployment</a></li>
<li><a href="#infra">10 · Infrastructure</a></li>
</ol></div></nav>
<main class="wrap">
<section id="overview">
<h2><span class="n">1</span>System Overview</h2>
<p class="lead">VerdictTank scores an uploaded proposal or RFP response against a rubric using a
panel of independent LLM judges, then aggregates their verdicts into a single, defensible
<b>PASS / BELOW</b> decision with per-criterion breakdowns and actionable fixes.</p>
<h3>The Consensus Problem</h3>
<p>A single LLM asked "is this proposal good?" produces a fragile answer: score drift between
runs, sycophancy toward confident prose, sensitivity to prompt phrasing, and no way to detect
when the model is simply wrong. VerdictTank treats scoring as a <b>consensus problem</b>, not a
single-inference problem. Three heterogeneous judges (different model families, different
training corpora) score the same artifact under an identical rubric. Divergence between judges is
a signal — it flags criteria where the artifact is genuinely ambiguous — and agreement is the
measure of confidence. The platform's core value is not any one model's opinion; it is the
<b>reproducible, auditable aggregation</b> of independent opinions.</p>
<div class="card">
<b>Design thesis.</b> A verdict is trustworthy when (a) it is produced by judges that do not
share failure modes, (b) the aggregation rule is deterministic and published, (c) every score
carries a rationale traceable to rubric criteria, and (d) the decision boundary is a single,
documented threshold rather than a black-box regressor.
</div>
<h3>Architecture Decisions</h3>
<table>
<tr><th>#</th><th>Decision</th><th>Rationale</th></tr>
<tr><td>AD-01</td><td><b>Three-judge heterogeneous panel</b> via LiteLLM (Anthropic, OpenAI, DeepSeek families).</td>
<td>Different corpora and RLHF regimes decorrelate errors. Odd count avoids ties in criterion-level majority checks.</td></tr>
<tr><td>AD-02</td><td><b>LiteLLM proxy as the single model gateway.</b> No direct provider SDK calls from app code.</td>
<td>Uniform auth, per-key budgets, request logging, and hot-swappable model routing without redeploying the app.</td></tr>
<tr><td>AD-03</td><td><b>Parallel judge dispatch</b> with per-judge timeout, not sequential.</td>
<td>Wall-clock latency is bounded by the slowest judge, not the sum. Enables a strict end-to-end latency budget.</td></tr>
<tr><td>AD-04</td><td><b>Binary verdict (PASS / BELOW)</b> against one published threshold, not a letter grade.</td>
<td>A single decision boundary is auditable and defensible to a buyer. Numeric sub-scores are retained for detail.</td></tr>
<tr><td>AD-05</td><td><b>6-stage fallback chain</b> for every judge slot.</td>
<td>Provider outages, rate limits, and malformed output must degrade gracefully to a still-valid verdict, never a hard failure.</td></tr>
<tr><td>AD-06</td><td><b>PII sanitization before any model call</b> (deterministic redaction pass).</td>
<td>No customer PII crosses a sub-processor boundary. Sanitization is idempotent and logged per session.</td></tr>
<tr><td>AD-07</td><td><b>Per-tenant corpus isolation</b> at the row and object-store prefix level.</td>
<td>One tenant's reference corpus can never leak into another's scoring context. Enforced in query layer and S3 prefix ACLs.</td></tr>
<tr><td>AD-08</td><td><b>Sub-processor training guard:</b> all provider calls set no-train / zero-retention flags where offered; DeepSeek routed through a no-retention endpoint.</td>
<td>Contractual and technical guarantee that customer content is never used to train third-party models.</td></tr>
<tr><td>AD-09</td><td><b>Review session as an explicit state machine</b> persisted in Postgres.</td>
<td>Every transition is durable and replayable. Crash recovery resumes from the last committed state, never re-charges a completed judge call.</td></tr>
<tr><td>AD-10</td><td><b>Fix-It rescore delta is measured, not promised.</b> Re-scoring after fixes produces a real before/after delta.</td>
<td>The product proves its own value: the customer sees the verdict actually move.</td></tr>
<tr><td>AD-11</td><td><b>White-Label access gated by a 5-condition ledger</b> returning HTTP 423 until all conditions clear.</td>
<td>Processor-role features cannot be enabled until legal, billing, and isolation prerequisites are verifiably satisfied.</td></tr>
<tr><td>AD-12</td><td><b>Stateless app tier, stateful data tier.</b> App containers hold no session state; Postgres + Redis + Wasabi S3 hold all durable state.</td>
<td>Horizontal scale and zero-downtime redeploys. Any app replica can serve any request.</td></tr>
</table>
<h3>Component Map</h3>
<pre class="ascii">
┌──────────────────────────────────────────────────────────────────────────┐
│ verdicttank.com (Caddy) │
│ TLS termination · HTTP/2 · automatic certs · reverse proxy · rate limit │
└───────────────┬───────────────────────────────────────────┬──────────────┘
│ │
┌─────────▼─────────┐ ┌─────────▼─────────┐
│ API (FastAPI) │ ◀── stateless ──▶ │ Worker (arq/RQ) │
│ auth · sessions │ │ judge dispatch │
│ webhooks · gate │ │ aggregate · fixit │
└───┬───────┬───────┘ └───┬───────────┬────┘
│ │ │ │
┌─────▼──┐ ┌──▼─────┐ ┌─────▼───┐ ┌────▼─────────┐
│Postgres│ │ Redis │ │ LiteLLM │ │ Wasabi S3 │
│ state │ │queue + │ │ gateway │ │ artifacts + │
│ + DDL │ │ cache │ │ 3 judges│ │ corpus (iso) │
└────────┘ └────────┘ └────┬────┘ └──────────────┘
┌────────────────────────────┼────────────────────────┐
▼ ▼ ▼
Anthropic family OpenAI family DeepSeek family
(judge slot A) (judge slot B) (judge slot C)
Observability: Prometheus scrapes API+Worker+LiteLLM /metrics → Grafana dashboards + alerts.
</pre>
<div class="note"><b>Tech stack.</b> FastAPI (Python 3.11) API tier · arq worker on Redis ·
PostgreSQL 16 (state + rubrics + verdicts) · Redis 7 (queue + rate-limit + short-cache) ·
LiteLLM proxy (model gateway) · Wasabi S3 (artifact + corpus object store) ·
Caddy 2 (TLS + reverse proxy) · Prometheus + Grafana (observability) ·
Docker Compose on netcup RS 4000.</div>
</section>
<section id="pipeline">
<h2><span class="n">2</span>Multimodel Scoring Pipeline</h2>
<p class="lead">Three independent judges score the same sanitized artifact in parallel through the
LiteLLM gateway. Per-criterion scores are aggregated deterministically into an overall score and a
binary verdict.</p>
<h3>Judge Roster (via LiteLLM)</h3>
<table>
<tr><th>Slot</th><th>Family</th><th>LiteLLM model alias</th><th>Role</th><th>Weight</th></tr>
<tr><td>A</td><td>Anthropic</td><td><code>judge-anthropic</code></td><td>Reasoning depth, requirement traceability</td><td>1.0</td></tr>
<tr><td>B</td><td>OpenAI</td><td><code>judge-openai</code></td><td>Structure, clarity, completeness</td><td>1.0</td></tr>
<tr><td>C</td><td>DeepSeek</td><td><code>judge-deepseek</code></td><td>Cost-anchor, adversarial skepticism</td><td>1.0</td></tr>
</table>
<p class="small">Aliases resolve to concrete provider models inside LiteLLM's <code>config.yaml</code>.
Swapping a judge's underlying model is a LiteLLM config change plus a reload — no application deploy.
Weights are configurable per rubric; default is equal (1.0) so all three judges count identically.</p>
<h3>Parallel Dispatch</h3>
<pre class="ascii">
sanitized_artifact + rubric
├──────────────┬──────────────┐ all three fire concurrently
▼ ▼ ▼ (asyncio.gather, per-judge timeout)
┌────────┐ ┌────────┐ ┌────────┐
│Judge A │ │Judge B │ │Judge C │ each: prompt → LiteLLM → JSON score
│ 8s TO │ │ 8s TO │ │ 8s TO │ on timeout/error → fallback chain
└───┬────┘ └───┬────┘ └───┬────┘
└─────────────┼─────────────┘
┌──────────────┐
│ Aggregator │ weighted mean per criterion → overall → PASS/BELOW
└──────┬───────┘
verdict record (persisted)
</pre>
<p>Dispatch uses <code>asyncio.gather(return_exceptions=True)</code>. A judge that raises or
times out does not abort the panel; its slot enters the fallback chain independently. The panel is
<b>valid</b> if at least 2 of 3 judges return a well-formed score (quorum = 2). With only 1 valid
judge the session transitions to <code>DEGRADED</code> and the verdict is flagged low-confidence.</p>
<h3>Scoring Schema (judge output contract)</h3>
<p>Every judge is instructed to return strict JSON matching this schema. The response is parsed and
validated; a schema violation triggers the fallback chain (stage 4, reformat).</p>
<pre>{
"$schema": "verdicttank/judge-score/v4",
"type": "object",
"required": ["judge","criteria","overall","rationale_summary"],
"properties": {
"judge": { "type": "string", "enum": ["judge-anthropic","judge-openai","judge-deepseek"] },
"criteria": {
"type": "array",
"items": {
"type": "object",
"required": ["id","score","weight","rationale"],
"properties": {
"id": { "type": "string" }, // rubric criterion id, e.g. "req_coverage"
"score": { "type": "number", "minimum": 0, "maximum": 100 },
"weight": { "type": "number", "minimum": 0, "maximum": 1 },
"rationale": { "type": "string", "maxLength": 600 },
"evidence": { "type": "array", "items": { "type": "string" } } // quoted spans from artifact
}
}
},
"overall": { "type": "number", "minimum": 0, "maximum": 100 },
"rationale_summary": { "type": "string", "maxLength": 1200 },
"flags": { "type": "array", "items": { "type": "string" } } // e.g. "missing_pricing"
}
}</pre>
<h3>Aggregation</h3>
<p>Aggregation is deterministic and published so a customer can reproduce the verdict by hand.</p>
<ol>
<li><b>Per-criterion aggregate.</b> For criterion <code>c</code>, compute the judge-weighted mean:
<code>agg_c = Σ(judge_weight_j × score_jc) / Σ(judge_weight_j)</code> over judges that returned a
valid score for <code>c</code>.</li>
<li><b>Criterion-weighted overall.</b> <code>OVERALL = Σ(rubric_weight_c × agg_c) / Σ(rubric_weight_c)</code>.</li>
<li><b>Agreement metric.</b> <code>spread_c = max_j(score_jc) min_j(score_jc)</code>. Any criterion
with <code>spread_c ≥ 25</code> is tagged <code>contested</code> and surfaced in the report.</li>
<li><b>Confidence.</b> <code>confidence = clamp(1 mean(spread_c)/100, 0, 1)</code>, degraded by
0.15 per missing judge below quorum.</li>
<li><b>Verdict.</b> <code>OVERALL ≥ threshold → PASS</code> else <code>BELOW</code>. Default
<code>threshold = 75</code>; configurable per rubric, stored on the verdict for auditability.</li>
</ol>
<div class="note"><b>Determinism guarantee.</b> Given the same three judge JSON outputs, the same
rubric weights, and the same threshold, the aggregator always produces the identical overall score
and verdict. Judge sampling temperature is pinned to <code>0.2</code> and <code>seed</code> is set
where the provider supports it; raw judge outputs are persisted so a verdict is fully replayable.</div>
<h3>6-Stage Fallback Chain (per judge slot)</h3>
<p>Each judge slot independently walks this chain. The chain guarantees a slot either yields a valid
score or is cleanly marked <code>UNAVAILABLE</code> (counting against quorum) — it never hangs.</p>
<table>
<tr><th>Stage</th><th>Trigger</th><th>Action</th><th>Budget</th></tr>
<tr><td>1 · Primary</td><td></td><td>Call the slot's configured model through LiteLLM.</td><td>8s</td></tr>
<tr><td>2 · Retry</td><td>5xx / network / timeout</td><td>Single retry with 500ms jitter backoff, same model.</td><td>+8s</td></tr>
<tr><td>3 · Sibling model</td><td>429 rate-limit or repeated 5xx</td><td>LiteLLM routes to the same family's alternate deployment (fallback map in config.yaml).</td><td>+8s</td></tr>
<tr><td>4 · Reformat</td><td>200 but invalid/non-JSON body</td><td>Re-prompt the model with the exact schema and the offending output, demanding valid JSON only.</td><td>+6s</td></tr>
<tr><td>5 · Cross-family substitute</td><td>Family fully unavailable</td><td>Borrow a spare deployment from another family, tagged <code>substituted</code> in the verdict (weight halved).</td><td>+8s</td></tr>
<tr><td>6 · Mark unavailable</td><td>All above exhausted</td><td>Slot → <code>UNAVAILABLE</code>. Panel proceeds on remaining judges if quorum (≥2) holds; else session → <code>DEGRADED</code>.</td><td>0s</td></tr>
</table>
<h3>Latency Budgets</h3>
<table>
<tr><th>Path</th><th>Target (p50)</th><th>Ceiling (p95)</th><th>Notes</th></tr>
<tr><td>Ingest + sanitize</td><td>400 ms</td><td>1.2 s</td><td>Deterministic redaction; scales with artifact size.</td></tr>
<tr><td>Parallel judge panel (happy path)</td><td>4.5 s</td><td>8 s</td><td>Bounded by slowest judge, not the sum. Per-judge timeout 8s.</td></tr>
<tr><td>Judge panel with one fallback hop</td><td></td><td>16 s</td><td>Stage 2/3 adds one budget window to the affected slot only.</td></tr>
<tr><td>Aggregation + persist</td><td>60 ms</td><td>200 ms</td><td>Pure CPU + one DB write.</td></tr>
<tr><td><b>End-to-end (submit → verdict)</b></td><td><b>~5 s</b></td><td><b>18 s</b></td><td>Hard wall-clock cap at 25 s → session marked <code>TIMEOUT</code>, partial verdict if quorum met.</td></tr>
<tr><td>Fix-It rescore (single criterion)</td><td>3 s</td><td>9 s</td><td>Re-runs only affected criteria across the panel when possible.</td></tr>
</table>
<h4>Error handling — pipeline</h4>
<ul>
<li><b>All judges fail (quorum lost):</b> session → <code>FAILED</code>; API returns <code>503</code>
with <code>Retry-After</code>; no charge recorded against the session's review credit.</li>
<li><b>Partial panel (2 of 3):</b> verdict produced, <code>confidence</code> reduced, response
includes <code>"degraded": true</code> and the unavailable slot id.</li>
<li><b>Malformed after reformat (stage 4 fails):</b> slot treated as <code>UNAVAILABLE</code>; raw
body stored for post-mortem in <code>judge_raw_output</code>.</li>
<li><b>Wall-clock cap hit:</b> in-flight judge calls are cancelled; if quorum already met the
partial verdict is committed as <code>TIMEOUT_PARTIAL</code>, else <code>FAILED</code>.</li>
</ul>
</section>
<section id="fixit">
<h2><span class="n">3</span>Fix-It Engine</h2>
<p class="lead">After a verdict, the Fix-It Engine converts each weak criterion into a ranked,
actionable fix, then re-scores the revised artifact to produce a measured before/after delta.</p>
<h3>Failure Modes (what Fix-It detects)</h3>
<table>
<tr><th>Mode</th><th>Detection signal</th><th>Example fix</th></tr>
<tr><td><code>MISSING_REQUIREMENT</code></td><td>Criterion <code>req_coverage</code> score &lt; 60 or judge <code>flags</code> contains a requirement id.</td><td>"RFP §4.2 asks for SOC 2 evidence — no security section addresses it. Add a compliance subsection citing your SOC 2 report."</td></tr>
<tr><td><code>WEAK_EVIDENCE</code></td><td>High-weight criterion scored mid-range with judge rationale citing "unsupported" / "no data".</td><td>"The ROI claim has no figures. Add a quantified savings table."</td></tr>
<tr><td><code>STRUCTURAL</code></td><td><code>structure</code> criterion low; ordering/section flags raised.</td><td>"Executive summary appears after pricing. Move it to the front."</td></tr>
<tr><td><code>CLARITY</code></td><td><code>clarity</code> low; long-sentence / jargon flags.</td><td>"Section 3 averages 40-word sentences. Break into shorter statements."</td></tr>
<tr><td><code>CONTESTED</code></td><td><code>spread_c ≥ 25</code> (judges disagree).</td><td>"Judges split on differentiation. Make the unique-value claim explicit and defensible."</td></tr>
<tr><td><code>COMPLIANCE_GAP</code></td><td>Mandatory rubric criterion below its hard floor.</td><td>"Insurance certificate is required and absent. This alone forces a BELOW verdict."</td></tr>
</table>
<h3>Prioritization Formula</h3>
<p>Fixes are ranked by expected verdict impact per unit of effort, so the customer fixes the things
that move the needle first.</p>
<div class="card mono">
priority_c = ( rubric_weight_c × gap_c × contested_bonus_c ) / effort_c<br><br>
where<br>
&nbsp;&nbsp;gap_c&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= max(0, target_score agg_c)&nbsp;&nbsp;// distance to a passing sub-score, target default 80<br>
&nbsp;&nbsp;contested_bonus_c = 1 + (spread_c ≥ 25 ? 0.5 : 0)&nbsp;// disagreement is high-value to resolve<br>
&nbsp;&nbsp;effort_c&nbsp;&nbsp;&nbsp;= { STRUCTURAL:1, CLARITY:1, WEAK_EVIDENCE:2, MISSING_REQUIREMENT:3, COMPLIANCE_GAP:3 }
</div>
<p>Fixes are returned sorted by <code>priority_c</code> descending. Each carries an
<code>estimated_lift</code> = <code>rubric_weight_c × gap_c</code> — the maximum points the overall
score can gain if that criterion reaches <code>target_score</code>. The UI shows a running
"projected verdict if all applied" sum, clamped so <code>OVERALL ≤ 100</code>.</p>
<h3>Rescore Delta</h3>
<p>When the customer submits a revised artifact (or accepts inline suggestions), the engine re-runs
the panel and reports a real, measured delta — never a promise.</p>
<pre>{
"$schema": "verdicttank/rescore-delta/v4",
"session_id": "rs_9f3a...",
"before": { "overall": 68.4, "verdict": "BELOW", "confidence": 0.71 },
"after": { "overall": 81.2, "verdict": "PASS", "confidence": 0.83 },
"delta": { "overall": 12.8, "verdict_changed": true },
"per_criterion": [
{ "id": "req_coverage", "before": 55.0, "after": 84.0, "delta": 29.0, "fix_applied": "fix_01" },
{ "id": "clarity", "before": 72.0, "after": 78.0, "delta": 6.0, "fix_applied": "fix_03" }
],
"rescored_at": "2026-08-11T14:22:08Z"
}</pre>
<p class="small">Optimization: if only a subset of criteria changed (detected by artifact diff), the
engine re-scores just those criteria across all three judges and reuses unchanged criterion scores —
cutting rescore latency and cost. Full re-scores are forced when the artifact diff exceeds 30% of tokens.</p>
<h3>Single-Tier Binary Verdict — PASS / BELOW</h3>
<p>VerdictTank issues exactly one of two verdicts. There is no letter grade, no star rating, and no
intermediate "maybe" tier. This is deliberate (AD-04): a buyer needs a defensible go/no-go.</p>
<div class="grid2">
<div class="card"><span class="pill-pass">PASS</span>
<p style="margin-top:10px"><code>OVERALL ≥ threshold</code> AND every mandatory criterion is at
or above its hard floor. The artifact meets the bar defined by the rubric.</p></div>
<div class="card"><span class="pill-below">BELOW</span>
<p style="margin-top:10px"><code>OVERALL &lt; threshold</code> OR any mandatory criterion is under
its hard floor (a single compliance gap forces BELOW regardless of overall score).</p></div>
</div>
<p class="small">The numeric <code>OVERALL</code>, per-criterion sub-scores, confidence, and contested
flags are always returned alongside the binary verdict for transparency — the binary is the decision,
the numbers are the justification.</p>
<h4>Error handling — Fix-It</h4>
<ul>
<li><b>No weak criteria (already PASS):</b> engine returns an empty fix list with
<code>"status":"no_action_needed"</code>; UI shows the passing report only.</li>
<li><b>Rescore panel loses quorum:</b> the <code>before</code> verdict is preserved unchanged;
response carries <code>"rescore_status":"degraded"</code> and does not overwrite the stored verdict.</li>
<li><b>Artifact unchanged from prior submission (hash match):</b> rescore is skipped;
returns <code>409 Conflict</code> with the existing verdict to avoid a redundant paid run.</li>
<li><b>Compliance floor still breached after fixes:</b> verdict stays <code>BELOW</code> even if
<code>OVERALL</code> crosses threshold; the blocking criterion is named in <code>blocking_criteria</code>.</li>
</ul>
</section>
<section id="dataflow">
<h2><span class="n">4</span>Data Flow</h2>
<p class="lead">A review moves through six durable stages. Each stage has an input contract, an output
contract, and a persisted state transition. The pipeline is crash-safe: recovery resumes at the last
committed stage.</p>
<h3>6-Stage Pipeline</h3>
<pre class="ascii">
①INGEST → ②SANITIZE → ③DISPATCH → ④AGGREGATE → ⑤FIXIT → ⑥PERSIST/DELIVER
│ │ │ │ │ │
upload redact PII 3 judges weighted ranked fixes verdict +
+ parse (idempotent) parallel mean → + rescore webhook +
artifact (LiteLLM) PASS/BELOW delta report URL
</pre>
<h4>① INGEST — input contract</h4>
<pre>{ "$schema":"verdicttank/ingest/v4",
"tenant_id":"t_8821", "rubric_id":"rub_default_v4",
"artifact":{ "filename":"proposal.pdf", "mime":"application/pdf",
"bytes_b64":"JVBERi0x...", "sha256":"a91f..." },
"options":{ "threshold":75, "webhook_url":"https://buyer.example/hooks/vt" } }</pre>
<p class="small">Parser extracts plain text (PDF/DOCX/MD/TXT). Artifact stored to Wasabi at
<code>s3://vt-artifacts/{tenant_id}/{session_id}/original</code>. Output: <code>artifact_text</code>
+ <code>token_count</code>.</p>
<h4>② SANITIZE — output contract</h4>
<pre>{ "$schema":"verdicttank/sanitized/v4",
"session_id":"rs_9f3a", "artifact_text":"...[REDACTED:EMAIL]... [REDACTED:PHONE]...",
"redactions":[ {"type":"EMAIL","count":3}, {"type":"PHONE","count":1}, {"type":"SSN","count":0} ],
"sanitize_hash":"7c2e..." }</pre>
<p class="small">Deterministic regex + NER redaction. Idempotent: re-running yields the identical
<code>sanitize_hash</code>. Only the sanitized text is ever sent to a judge (AD-06).</p>
<h4>③ DISPATCH — output contract (per judge, see §2 scoring schema)</h4>
<pre>{ "session_id":"rs_9f3a", "judge_results":[
{"judge":"judge-anthropic","status":"ok","overall":66,"criteria":[...]},
{"judge":"judge-openai","status":"ok","overall":71,"criteria":[...]},
{"judge":"judge-deepseek","status":"substituted","overall":68,"criteria":[...]} ],
"quorum_met":true, "degraded":false }</pre>
<h4>④ AGGREGATE — output contract</h4>
<pre>{ "$schema":"verdicttank/verdict/v4",
"session_id":"rs_9f3a", "overall":68.4, "verdict":"BELOW", "threshold":75,
"confidence":0.71,
"criteria":[ {"id":"req_coverage","agg":55.0,"spread":18,"contested":false},
{"id":"clarity","agg":72.0,"spread":9,"contested":false},
{"id":"differentiation","agg":60.0,"spread":31,"contested":true} ],
"blocking_criteria":[], "computed_at":"2026-08-11T14:20:55Z" }</pre>
<h4>⑤ FIXIT — output contract (see §3)</h4>
<pre>{ "session_id":"rs_9f3a", "status":"action_needed",
"fixes":[ {"id":"fix_01","criterion":"req_coverage","mode":"MISSING_REQUIREMENT",
"priority":24.8,"estimated_lift":9.0,"text":"Add SOC 2 evidence section..."} ],
"projected_overall":81.2 }</pre>
<h4>⑥ PERSIST / DELIVER — output contract</h4>
<pre>{ "session_id":"rs_9f3a", "state":"COMPLETED",
"report_url":"https://verdicttank.com/r/rs_9f3a",
"webhook_delivered":true, "verdict":"BELOW" }</pre>
<h3>Review Session State Machine</h3>
<pre class="ascii">
┌───────────────────────────────────────────────┐
│ │
(POST /reviews) ▼ │
─────────────▶ [ RECEIVED ] │
│ ingest+parse ok │
▼ │
[ SANITIZED ] │
│ redaction committed │
▼ │
[ DISPATCHING ] ──quorum lost──▶ [ FAILED ] ◀──wall-clock cap (no quorum)
│ ≥2 judges ok │
▼ │ (terminal, 503 to caller)
[ AGGREGATED ] ──1 judge only──▶ [ DEGRADED ]
│ verdict computed │ (low-confidence verdict stored)
▼ ▼
[ SCORED ] ◀──────────────────────────┘
│ │
fixes needed│ │already PASS / no action
▼ ▼
[ FIXIT_READY ] [ COMPLETED ]───(webhook + report)───▶ (terminal)
│ rescore submitted ▲
▼ │ verdict updated
[ RESCORING ] ──quorum lost──▶ keeps prior verdict, back to [ SCORED ]
│ new verdict computed │
└────────────────────────────────────┘
Timeout guard: any non-terminal state exceeding 25s wall-clock → [ TIMEOUT_PARTIAL ]
(if quorum already met, commit partial verdict → SCORED) else → [ FAILED ].
Crash recovery: worker restart reloads last committed state; completed judge calls
are cached by (session_id, judge, sanitize_hash) and never re-billed.
</pre>
<table>
<tr><th>State</th><th>Meaning</th><th>Terminal?</th></tr>
<tr><td><code>RECEIVED</code></td><td>Upload accepted, parse pending.</td><td>no</td></tr>
<tr><td><code>SANITIZED</code></td><td>PII redacted, ready for judges.</td><td>no</td></tr>
<tr><td><code>DISPATCHING</code></td><td>Judge panel in flight.</td><td>no</td></tr>
<tr><td><code>AGGREGATED</code></td><td>Scores combined, verdict pending write.</td><td>no</td></tr>
<tr><td><code>SCORED</code></td><td>Verdict committed, report available.</td><td>no</td></tr>
<tr><td><code>FIXIT_READY</code></td><td>Fixes generated, awaiting revised artifact.</td><td>no</td></tr>
<tr><td><code>RESCORING</code></td><td>Revised artifact re-scoring.</td><td>no</td></tr>
<tr><td><code>DEGRADED</code></td><td>Verdict from &lt;quorum judges; low confidence.</td><td>no</td></tr>
<tr><td><code>COMPLETED</code></td><td>Delivered (webhook + report).</td><td>yes</td></tr>
<tr><td><code>TIMEOUT_PARTIAL</code></td><td>Wall-clock cap hit; partial verdict committed.</td><td>yes*</td></tr>
<tr><td><code>FAILED</code></td><td>Quorum never met; no charge.</td><td>yes</td></tr>
</table>
<p class="small">*<code>TIMEOUT_PARTIAL</code> is terminal for the original run but a new rescore may
reopen the session into <code>RESCORING</code>.</p>
</section>
<section id="api">
<h2><span class="n">5</span>API Surface</h2>
<p class="lead">REST/JSON over HTTPS. Base URL <code>https://api.verdicttank.com/v4</code>. All bodies
UTF-8 JSON. All timestamps RFC 3339 UTC. All IDs are prefixed opaque strings.</p>
<h3>Authentication</h3>
<ul>
<li><b>API keys</b><code>Authorization: Bearer vt_live_XXXX</code> for server-to-server. Keys are
tenant-scoped, hashed at rest (argon2id), and carry a role (<code>viewer</code>,
<code>reviewer</code>, <code>admin</code>). Test keys prefixed <code>vt_test_</code>.</li>
<li><b>Session JWT</b> — short-lived (15 min) bearer for browser clients, issued by
<code>POST /auth/token</code>, refreshed via <code>POST /auth/refresh</code>. Contains
<code>tenant_id</code>, <code>role</code>, <code>exp</code>. Signed HS256 with a rotated secret.</li>
<li><b>Webhook signature</b> — outbound webhooks are signed <code>X-VT-Signature: sha256=HMAC(body)</code>
with the tenant's webhook secret.</li>
</ul>
<h3>Rate Limiting</h3>
<p>Token-bucket per API key, enforced in Redis. Limits returned on every response:</p>
<pre>X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1754923200
Retry-After: 12 # only on 429</pre>
<table>
<tr><th>Plan</th><th>Reviews/min</th><th>Reviews/day</th><th>Burst</th></tr>
<tr><td>Free</td><td>2</td><td>10</td><td>3</td></tr>
<tr><td>Pro ($79/mo)</td><td>20</td><td>500 (fair-use)</td><td>30</td></tr>
<tr><td>Enterprise ($299/mo)</td><td>60</td><td>negotiated</td><td>100</td></tr>
</table>
<h3>Endpoints</h3>
<ul class="eplist">
<li><span class="tag t-post">POST</span><code>/auth/token</code> — exchange API key or credentials for a session JWT.</li>
<li><span class="tag t-post">POST</span><code>/reviews</code> — create a review (ingest artifact, start pipeline).</li>
<li><span class="tag t-get">GET</span><code>/reviews/{id}</code> — fetch a review session + verdict.</li>
<li><span class="tag t-get">GET</span><code>/reviews/{id}/verdict</code> — verdict + per-criterion detail only.</li>
<li><span class="tag t-get">GET</span><code>/reviews/{id}/fixes</code> — Fix-It ranked fix list.</li>
<li><span class="tag t-post">POST</span><code>/reviews/{id}/rescore</code> — submit revised artifact, get before/after delta.</li>
<li><span class="tag t-get">GET</span><code>/reviews</code> — list tenant reviews (paginated, filterable by verdict/state).</li>
<li><span class="tag t-get">GET</span><code>/rubrics</code> · <span class="tag t-post">POST</span><code>/rubrics</code> — manage scoring rubrics.</li>
<li><span class="tag t-get">GET</span><code>/webhooks</code> · <span class="tag t-post">POST</span><code>/webhooks</code> · <span class="tag t-del">DELETE</span><code>/webhooks/{id}</code> — manage webhook endpoints.</li>
<li><span class="tag t-get">GET</span><code>/whitelabel/status</code> — 5-condition gate ledger (see §8).</li>
<li><span class="tag t-get">GET</span><code>/healthz</code> · <span class="tag t-get">GET</span><code>/metrics</code> — liveness + Prometheus.</li>
</ul>
<h4>POST /reviews — request</h4>
<pre>POST /v4/reviews
Authorization: Bearer vt_live_XXXX
Content-Type: application/json
{ "rubric_id":"rub_default_v4",
"artifact":{ "filename":"proposal.pdf","mime":"application/pdf","bytes_b64":"JVBERi0x..." },
"options":{ "threshold":75, "webhook_url":"https://buyer.example/hooks/vt", "async":true } }</pre>
<h4>POST /reviews — response (202 async)</h4>
<pre>HTTP/1.1 202 Accepted
Location: /v4/reviews/rs_9f3a
{ "session_id":"rs_9f3a", "state":"RECEIVED", "poll_url":"/v4/reviews/rs_9f3a",
"estimated_ready_s":6 }</pre>
<p class="small">With <code>"async":false</code> the call blocks up to the 25 s wall-clock cap and
returns <code>200</code> with the full verdict inline.</p>
<h4>GET /reviews/{id} — response (200)</h4>
<pre>{ "session_id":"rs_9f3a", "state":"SCORED", "tenant_id":"t_8821",
"verdict":"BELOW", "overall":68.4, "threshold":75, "confidence":0.71, "degraded":false,
"criteria":[ {"id":"req_coverage","agg":55.0,"spread":18,"contested":false,
"rationales":{"judge-anthropic":"...","judge-openai":"...","judge-deepseek":"..."}} ],
"blocking_criteria":[], "report_url":"https://verdicttank.com/r/rs_9f3a",
"created_at":"2026-08-11T14:20:49Z", "computed_at":"2026-08-11T14:20:55Z" }</pre>
<h4>POST /reviews/{id}/rescore — request/response</h4>
<pre>POST /v4/reviews/rs_9f3a/rescore
{ "artifact":{ "filename":"proposal_v2.pdf","mime":"application/pdf","bytes_b64":"..." } }
→ 200 { ...rescore-delta/v4 object (see §3)... }
→ 409 { "error":"unchanged_artifact","message":"sha256 matches prior submission" }</pre>
<h3>Webhooks</h3>
<p>Registered endpoints receive signed POSTs on state transitions. Events: <code>review.scored</code>,
<code>review.completed</code>, <code>review.failed</code>, <code>review.rescored</code>.</p>
<pre>POST {webhook_url}
X-VT-Signature: sha256=9a3c...
X-VT-Event: review.scored
X-VT-Delivery: whd_44a1
{ "event":"review.scored", "session_id":"rs_9f3a", "verdict":"BELOW",
"overall":68.4, "occurred_at":"2026-08-11T14:20:55Z" }</pre>
<p class="small">Delivery: at-least-once with exponential backoff (max 6 attempts over ~1h). Consumers
must be idempotent on <code>X-VT-Delivery</code>. Verify by recomputing
<code>HMAC-SHA256(secret, raw_body)</code> and constant-time comparing to <code>X-VT-Signature</code>.</p>
<h3>Error Model</h3>
<p>All errors share one envelope. HTTP status conveys the class; <code>error</code> conveys the code.</p>
<pre>{ "error":"quorum_lost",
"message":"Only 1 of 3 judges returned a valid score.",
"session_id":"rs_9f3a", "request_id":"req_7c2e", "retryable":true }</pre>
<table>
<tr><th>HTTP</th><th>error code</th><th>Meaning</th><th>Retryable</th></tr>
<tr><td>400</td><td><code>invalid_request</code></td><td>Malformed body / unsupported mime / missing field.</td><td>no</td></tr>
<tr><td>401</td><td><code>unauthenticated</code></td><td>Missing/invalid API key or expired JWT.</td><td>no</td></tr>
<tr><td>403</td><td><code>forbidden</code></td><td>Role lacks permission (e.g. viewer creating a review).</td><td>no</td></tr>
<tr><td>404</td><td><code>not_found</code></td><td>Unknown session/rubric/webhook id (or cross-tenant access).</td><td>no</td></tr>
<tr><td>409</td><td><code>unchanged_artifact</code></td><td>Rescore artifact identical to prior (hash match).</td><td>no</td></tr>
<tr><td>413</td><td><code>artifact_too_large</code></td><td>Artifact exceeds size/token cap.</td><td>no</td></tr>
<tr><td>422</td><td><code>unprocessable_artifact</code></td><td>Parse failed (corrupt PDF, empty text).</td><td>no</td></tr>
<tr><td>423</td><td><code>whitelabel_locked</code></td><td>White-Label gate conditions unmet (see §8).</td><td>no</td></tr>
<tr><td>429</td><td><code>rate_limited</code></td><td>Bucket exhausted; see <code>Retry-After</code>.</td><td>yes</td></tr>
<tr><td>503</td><td><code>quorum_lost</code></td><td>Judge panel failed to reach quorum; no charge recorded.</td><td>yes</td></tr>
<tr><td>504</td><td><code>pipeline_timeout</code></td><td>Wall-clock cap hit with no partial verdict.</td><td>yes</td></tr>
</table>
</section>
<section id="datamodel">
<h2><span class="n">6</span>Data Model</h2>
<p class="lead">PostgreSQL 16. Multi-tenant with <code>tenant_id</code> on every business table and
row-level security enforced by the query layer. All money is integer cents; all scores are numeric(5,2).</p>
<h3>Entity Relationships</h3>
<pre class="ascii">
tenants ──1:N── api_keys
├──1:N── rubrics ──1:N── rubric_criteria
├──1:N── review_sessions ──1:N── judge_results ──1:N── judge_criterion_scores
│ │
│ ├──1:1── verdicts
│ └──1:N── fixes
├──1:N── webhooks
└──1:1── whitelabel_ledger
</pre>
<h3>DDL</h3>
<pre>CREATE TABLE tenants (
tenant_id TEXT PRIMARY KEY, -- 't_8821'
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free'
CHECK (plan IN ('free','pro','enterprise','whitelabel')),
data_role TEXT NOT NULL DEFAULT 'controller'
CHECK (data_role IN ('controller','joint_controller','processor')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE api_keys (
key_id TEXT PRIMARY KEY, -- 'ak_...'
tenant_id TEXT NOT NULL REFERENCES tenants ON DELETE CASCADE,
key_hash TEXT NOT NULL, -- argon2id(secret)
role TEXT NOT NULL CHECK (role IN ('viewer','reviewer','admin')),
mode TEXT NOT NULL CHECK (mode IN ('live','test')),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_api_keys_tenant ON api_keys(tenant_id);
CREATE TABLE rubrics (
rubric_id TEXT PRIMARY KEY, -- 'rub_default_v4'
tenant_id TEXT REFERENCES tenants ON DELETE CASCADE, -- NULL = system rubric
name TEXT NOT NULL,
threshold NUMERIC(5,2) NOT NULL DEFAULT 75.00,
version TEXT NOT NULL DEFAULT 'v4',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE rubric_criteria (
criterion_id TEXT PRIMARY KEY, -- 'req_coverage'
rubric_id TEXT NOT NULL REFERENCES rubrics ON DELETE CASCADE,
label TEXT NOT NULL,
weight NUMERIC(4,3) NOT NULL CHECK (weight BETWEEN 0 AND 1),
is_mandatory BOOLEAN NOT NULL DEFAULT false,
hard_floor NUMERIC(5,2) NOT NULL DEFAULT 0.00, -- mandatory floor; below = forces BELOW
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX ix_criteria_rubric ON rubric_criteria(rubric_id);
CREATE TABLE review_sessions (
session_id TEXT PRIMARY KEY, -- 'rs_9f3a'
tenant_id TEXT NOT NULL REFERENCES tenants ON DELETE CASCADE,
rubric_id TEXT NOT NULL REFERENCES rubrics,
state TEXT NOT NULL DEFAULT 'RECEIVED'
CHECK (state IN ('RECEIVED','SANITIZED','DISPATCHING','AGGREGATED',
'SCORED','FIXIT_READY','RESCORING','DEGRADED',
'COMPLETED','TIMEOUT_PARTIAL','FAILED')),
artifact_sha TEXT NOT NULL, -- sha256 of original artifact
sanitize_hash TEXT, -- idempotency key for judge cache
s3_key TEXT NOT NULL, -- vt-artifacts/{tenant}/{session}/original
threshold NUMERIC(5,2) NOT NULL,
webhook_url TEXT,
charged BOOLEAN NOT NULL DEFAULT false, -- billing guard (FAILED never charged)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_sessions_tenant_state ON review_sessions(tenant_id, state);
CREATE TABLE judge_results (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES review_sessions ON DELETE CASCADE,
judge TEXT NOT NULL, -- 'judge-anthropic' | ...
status TEXT NOT NULL CHECK (status IN ('ok','substituted','unavailable')),
overall NUMERIC(5,2),
fallback_stage SMALLINT NOT NULL DEFAULT 1, -- which of the 6 stages produced this
latency_ms INT,
raw_output JSONB, -- persisted for replay/post-mortem
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, judge) -- idempotent per (session,judge)
);
CREATE TABLE judge_criterion_scores (
id BIGSERIAL PRIMARY KEY,
judge_result_id BIGINT NOT NULL REFERENCES judge_results ON DELETE CASCADE,
criterion_id TEXT NOT NULL,
score NUMERIC(5,2) NOT NULL CHECK (score BETWEEN 0 AND 100),
rationale TEXT,
evidence JSONB
);
CREATE INDEX ix_jcs_result ON judge_criterion_scores(judge_result_id);
CREATE TABLE verdicts (
session_id TEXT PRIMARY KEY REFERENCES review_sessions ON DELETE CASCADE,
verdict TEXT NOT NULL CHECK (verdict IN ('PASS','BELOW')),
overall NUMERIC(5,2) NOT NULL,
threshold NUMERIC(5,2) NOT NULL,
confidence NUMERIC(4,3) NOT NULL,
degraded BOOLEAN NOT NULL DEFAULT false,
criteria_agg JSONB NOT NULL, -- [{id,agg,spread,contested}]
blocking_criteria JSONB NOT NULL DEFAULT '[]',
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE fixes (
fix_id TEXT PRIMARY KEY, -- 'fix_01'
session_id TEXT NOT NULL REFERENCES review_sessions ON DELETE CASCADE,
criterion_id TEXT NOT NULL,
mode TEXT NOT NULL, -- MISSING_REQUIREMENT | WEAK_EVIDENCE | ...
priority NUMERIC(7,3) NOT NULL,
estimated_lift NUMERIC(5,2) NOT NULL,
body TEXT NOT NULL,
applied BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX ix_fixes_session ON fixes(session_id);
CREATE TABLE webhooks (
webhook_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants ON DELETE CASCADE,
url TEXT NOT NULL,
secret_hash TEXT NOT NULL,
events TEXT[] NOT NULL DEFAULT '{review.scored,review.completed}',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE whitelabel_ledger (
tenant_id TEXT PRIMARY KEY REFERENCES tenants ON DELETE CASCADE,
dpa_signed BOOLEAN NOT NULL DEFAULT false,
billing_active BOOLEAN NOT NULL DEFAULT false,
corpus_isolated BOOLEAN NOT NULL DEFAULT false,
branding_approved BOOLEAN NOT NULL DEFAULT false,
subprocessor_ack BOOLEAN NOT NULL DEFAULT false,
unlocked_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);</pre>
<p class="small">Row-level security policies (omitted for brevity) restrict every business table to
<code>tenant_id = current_setting('app.tenant_id')</code>. The app sets this GUC per request from the
authenticated key, so a query can never span tenants even on a coding error.</p>
</section>
<section id="security">
<h2><span class="n">7</span>Security</h2>
<p class="lead">Customer proposals are confidential business documents. The security model assumes
every judge is an external sub-processor and every tenant is mutually distrustful.</p>
<h3>PII Sanitization</h3>
<p>Sanitization (data-flow stage ②) runs <b>before any bytes leave the trust boundary</b> for a model
call. It is deterministic and idempotent so a verdict is reproducible and the redaction is auditable.</p>
<ul>
<li><b>Detectors:</b> regex passes for EMAIL, PHONE, SSN/EIN, credit-card (Luhn-checked), IBAN,
street address; plus a NER pass for PERSON and ORG when the tenant enables aggressive mode.</li>
<li><b>Replacement:</b> each match becomes a typed token <code>[REDACTED:EMAIL]</code>. A per-session
salted map (never sent to judges) allows post-hoc rehydration in the customer's own report view only.</li>
<li><b>Idempotency:</b> <code>sanitize_hash = sha256(sanitized_text)</code> is the judge-cache key —
re-scoring identical sanitized text reuses cached judge results and never re-bills.</li>
<li><b>Audit:</b> the <code>redactions</code> count vector (no raw values) is stored on the session.</li>
</ul>
<div class="warnbox"><b>Fail-closed.</b> If the sanitizer errors or a detector library fails to load,
the session transitions to <code>FAILED</code> rather than dispatching unsanitized text. Redaction is
never skipped to "save the run."</div>
<h3>Corpus Isolation</h3>
<p>A tenant's reference corpus (past winning proposals, style guides) is never visible to another
tenant's scoring context. Enforced at three layers (AD-07):</p>
<ul>
<li><b>Storage:</b> S3 prefix <code>s3://vt-corpus/{tenant_id}/…</code> with a bucket policy denying
cross-prefix reads; each tenant's IAM-scoped credentials cannot list outside their prefix.</li>
<li><b>Database:</b> row-level security keyed on <code>tenant_id</code> GUC (see §6).</li>
<li><b>Retrieval:</b> corpus lookups always inject <code>WHERE tenant_id = :current</code>; the query
builder refuses to run a corpus query without a bound tenant.</li>
</ul>
<h3>Sub-Processor Training Guard</h3>
<p>Customer content must never train a third-party model (AD-08). Enforced at the LiteLLM boundary:</p>
<ul>
<li>Every judge call sets provider no-train / zero-data-retention flags where offered
(e.g. OpenAI <code>store:false</code>, Anthropic no-retention headers).</li>
<li>DeepSeek traffic is routed only through a contracted no-retention endpoint; if that route is
unavailable, the DeepSeek slot is marked <code>UNAVAILABLE</code> rather than falling back to a
retaining endpoint — the training guard overrides the fallback chain.</li>
<li>LiteLLM request logs store metadata + token counts only, never artifact text.</li>
<li>Contractual: each provider is a named sub-processor in the DPA with a no-training clause; the
tenant's <code>subprocessor_ack</code> ledger flag records acknowledgment (see §8).</li>
</ul>
<h3>Encryption</h3>
<table>
<tr><th>Layer</th><th>Control</th></tr>
<tr><td>In transit (edge)</td><td>TLS 1.3 via Caddy; HSTS; automatic cert renewal.</td></tr>
<tr><td>In transit (internal)</td><td>App↔Postgres, App↔Redis, App↔LiteLLM on the Docker private network; no plaintext service exposed to the host public interface.</td></tr>
<tr><td>At rest (DB)</td><td>Postgres volume on encrypted disk; secrets columns (key_hash, secret_hash) are one-way hashed (argon2id), never reversible.</td></tr>
<tr><td>At rest (object store)</td><td>Wasabi S3 server-side encryption (SSE) on all objects; artifact objects lifecycle-expired after retention window.</td></tr>
<tr><td>Secrets</td><td>Provider keys and JWT signing secret injected via environment from a root-only <code>.env</code> (chmod 600), never committed; rotated on a schedule.</td></tr>
</table>
<h3>RBAC</h3>
<table>
<tr><th>Role</th><th>Read verdicts</th><th>Create/rescore reviews</th><th>Manage keys/webhooks/rubrics</th><th>White-Label admin</th></tr>
<tr><td><code>viewer</code></td><td></td><td></td><td></td><td></td></tr>
<tr><td><code>reviewer</code></td><td></td><td></td><td></td><td></td></tr>
<tr><td><code>admin</code></td><td></td><td></td><td></td><td>✔ (subject to §8 gate)</td></tr>
</table>
<p class="small">Roles are carried on the API key and in the session JWT. Every mutating endpoint
checks role before touching state; a role violation returns <code>403 forbidden</code>. Cross-tenant
access (valid key, wrong tenant's resource id) returns <code>404 not_found</code> — the platform does
not reveal that another tenant's resource exists.</p>
<h4>Error handling — security</h4>
<ul>
<li><b>Sanitizer failure:</b> fail-closed → session <code>FAILED</code>, no dispatch, alert fired.</li>
<li><b>Training-guard route down:</b> affected judge slot <code>UNAVAILABLE</code>; guard never bypassed.</li>
<li><b>RLS/GUC unset:</b> queries against business tables raise and abort the request (defense in depth against a missing tenant context).</li>
<li><b>Expired/rotated JWT secret:</b> old tokens fail verification → <code>401</code>; clients refresh via <code>/auth/refresh</code>.</li>
</ul>
</section>
<section id="wlgate">
<h2><span class="n">8</span>White-Label Gate</h2>
<p class="lead">White-Label makes VerdictTank a <b>processor</b> acting on a customer's behalf under their
brand. Processor-role features stay locked behind a 5-condition ledger until every prerequisite is
verifiably satisfied (AD-11). Any locked call returns <b>HTTP 423 Locked</b>.</p>
<h3>5-Condition Ledger</h3>
<p>Backed by the <code>whitelabel_ledger</code> table (§6). All five booleans must be <code>true</code>
before <code>unlocked_at</code> is stamped and White-Label endpoints activate.</p>
<table>
<tr><th>#</th><th>Ledger flag</th><th>Condition</th><th>Owner / evidence</th></tr>
<tr><td>1</td><td><code>dpa_signed</code></td><td>Data Processing Agreement executed (processor role, no-training sub-processor clause).</td><td>Legal — signed DPA on file.</td></tr>
<tr><td>2</td><td><code>billing_active</code></td><td>White-Label plan billing in good standing (no failed/overdue invoice).</td><td>Billing — active subscription record.</td></tr>
<tr><td>3</td><td><code>corpus_isolated</code></td><td>Dedicated S3 corpus prefix + RLS partition provisioned and verified for the tenant.</td><td>Platform — isolation probe passes.</td></tr>
<tr><td>4</td><td><code>branding_approved</code></td><td>Custom logo, domain, and report styling reviewed and approved (no trademark conflict).</td><td>Ops — branding checklist signed off.</td></tr>
<tr><td>5</td><td><code>subprocessor_ack</code></td><td>Tenant has acknowledged the current sub-processor list (Anthropic, OpenAI, DeepSeek) and no-training guarantee.</td><td>Legal/tenant — acknowledgment timestamp.</td></tr>
</table>
<div class="note"><b>Unlock rule.</b>
<code>unlocked = dpa_signed AND billing_active AND corpus_isolated AND branding_approved AND subprocessor_ack</code>.
On the transition to all-true the platform sets <code>unlocked_at = now()</code> in a single
transaction. If any flag later flips to <code>false</code> (e.g. billing lapses), White-Label
endpoints immediately re-lock and return 423 again — the gate is evaluated on every request, not cached.</div>
<h3>GET /whitelabel/status — response (200)</h3>
<pre>{ "tenant_id":"t_8821", "unlocked":false, "unlocked_at":null,
"conditions":{
"dpa_signed":true,
"billing_active":true,
"corpus_isolated":false, // ← blocking
"branding_approved":false, // ← blocking
"subprocessor_ack":true
},
"blocking":["corpus_isolated","branding_approved"] }</pre>
<h3>HTTP 423 Response (locked feature access)</h3>
<p>Any White-Label-scoped endpoint (custom-branded report render, processor-mode review, tenant-branded
webhook) invoked while the ledger is incomplete returns:</p>
<pre>HTTP/1.1 423 Locked
Content-Type: application/json
{ "error":"whitelabel_locked",
"message":"White-Label features are locked until all onboarding conditions are met.",
"tenant_id":"t_8821",
"blocking":["corpus_isolated","branding_approved"],
"status_url":"/v4/whitelabel/status",
"retryable":false }</pre>
<p class="small">423 is chosen deliberately over 403: the resource is not forbidden by role, it is
<i>temporarily locked pending a state change the tenant can resolve</i>. The <code>blocking</code>
array tells the caller exactly which conditions remain, and <code>status_url</code> points at the
live ledger. Once all conditions clear, the same call succeeds with no code change on the client side.</p>
</section>
<section id="deploy">
<h2><span class="n">9</span>Deployment Topology</h2>
<p class="lead">Single netcup RS 4000 host running Docker Compose. Caddy terminates TLS and reverse-proxies
to the app tier; Prometheus + Grafana provide observability; Wasabi S3 holds artifacts and corpus.</p>
<h3>Host</h3>
<table>
<tr><th>Attribute</th><th>Value</th></tr>
<tr><td>Provider / plan</td><td>netcup RS 4000 (root server)</td></tr>
<tr><td>Origin (app3)</td><td><code>152.53.241.111</code></td></tr>
<tr><td>Orchestration</td><td>Docker Compose (single-host, multi-container)</td></tr>
<tr><td>Edge</td><td>Caddy 2 — automatic TLS, HTTP/2, reverse proxy, per-route rate limit</td></tr>
<tr><td>Object store</td><td>Wasabi S3 (<code>vt-artifacts</code>, <code>vt-corpus</code>, <code>vt-backups</code>)</td></tr>
<tr><td>Observability</td><td>Prometheus (scrape) + Grafana (dashboards + alerts)</td></tr>
</table>
<h3>Topology Diagram</h3>
<pre class="ascii">
Internet
│ 443
┌──▼───────────────────────────────────────── netcup RS 4000 (app3) ──────────────┐
│ │
│ ┌────────────┐ verdicttank.com / api.verdicttank.com │
│ │ Caddy 2 │ TLS · HTTP/2 · rate-limit · reverse proxy │
│ └─────┬──────┘ │
│ │ (docker private net: vtnet) │
│ ┌────▼─────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ api │ │ worker │ │ postgres │ │ redis │ │ litellm │ │
│ │ FastAPI │◀─▶│ arq │◀─▶│ 16 │ │ 7 │ │ gateway │──┼─▶ providers
│ │ (uvicorn)│ │ │ │ (vol) │ │ (vol) │ │ (3 judges) │ │ (Anthropic/
│ └────┬─────┘ └────┬─────┘ └──────────┘ └──────────┘ └────────────────┘ │ OpenAI/
│ │ │ │ DeepSeek)
│ ┌────▼──────────────▼────┐ ┌────────────┐ ┌───────────┐ │
│ │ /metrics │───▶│ Prometheus │──▶│ Grafana │ dashboards+alerts │
│ └────────────────────────┘ └────────────┘ └───────────┘ │
│ │
│ nightly: pg_dump + artifact sync ───────────────────────────────────────────────┼─▶ Wasabi S3
└─────────────────────────────────────────────────────────────────────────────────┘
</pre>
<h3>docker-compose.yml (reference)</h3>
<pre>services:
caddy:
image: caddy:2
ports: ["80:80","443:443"]
volumes: ["./Caddyfile:/etc/caddy/Caddyfile","caddy_data:/data"]
networks: [vtnet]
depends_on: [api]
api:
build: ./app
command: uvicorn vt.main:app --host 0.0.0.0 --port 8000 --workers 4
env_file: [.env]
networks: [vtnet]
depends_on: [postgres, redis, litellm]
healthcheck:
test: ["CMD","curl","-fsS","http://localhost:8000/healthz"]
interval: 15s
worker:
build: ./app
command: arq vt.worker.WorkerSettings
env_file: [.env]
networks: [vtnet]
depends_on: [postgres, redis, litellm]
postgres:
image: postgres:16
environment: [POSTGRES_DB=vt, POSTGRES_USER=vt]
env_file: [.env] # POSTGRES_PASSWORD
volumes: ["pg_data:/var/lib/postgresql/data"]
networks: [vtnet]
redis:
image: redis:7
command: ["redis-server","--appendonly","yes"]
volumes: ["redis_data:/data"]
networks: [vtnet]
litellm:
image: ghcr.io/berriai/litellm:main-stable
command: ["--config","/app/config.yaml"]
volumes: ["./litellm/config.yaml:/app/config.yaml"]
env_file: [.env] # provider keys, no-retention routes
networks: [vtnet]
prometheus:
image: prom/prometheus
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml","prom_data:/prometheus"]
networks: [vtnet]
grafana:
image: grafana/grafana
volumes: ["grafana_data:/var/lib/grafana"]
networks: [vtnet]
depends_on: [prometheus]
networks: { vtnet: { driver: bridge } }
volumes: { caddy_data: {}, pg_data: {}, redis_data: {}, prom_data: {}, grafana_data: {} }</pre>
<h3>Caddyfile (reference)</h3>
<pre>verdicttank.com {
encode gzip zstd
reverse_proxy api:8000
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
api.verdicttank.com {
encode gzip zstd
rate_limit { zone api { key {remote_host}; events 60; window 1m } }
reverse_proxy api:8000
}</pre>
<h3>Observability</h3>
<ul>
<li><b>Prometheus</b> scrapes <code>/metrics</code> from api, worker, and litellm every 15 s.</li>
<li><b>Key metrics:</b> <code>vt_review_latency_seconds</code> (histogram), <code>vt_judge_fallback_stage</code>
(counter by stage), <code>vt_quorum_lost_total</code>, <code>vt_verdict_total{verdict}</code>,
<code>vt_sanitize_failures_total</code>.</li>
<li><b>Grafana alerts:</b> p95 end-to-end &gt; 18 s (5 min), quorum-loss rate &gt; 1% (10 min),
any sanitizer failure (immediate), a judge slot stuck at fallback stage ≥5 (5 min).</li>
</ul>
<h3>Backups &amp; DR</h3>
<ul>
<li>Nightly <code>pg_dump</code><code>s3://vt-backups/pg/</code> (Wasabi), 30-day retention.</li>
<li>Artifact + corpus objects already durable in Wasabi (SSE); versioning enabled on
<code>vt-artifacts</code>.</li>
<li>Config-as-code: compose file, Caddyfile, litellm <code>config.yaml</code>, and DDL migrations in
the infra repo — full host rebuild from repo + <code>.env</code> + latest pg dump.</li>
</ul>
<h4>Error handling — deployment</h4>
<ul>
<li><b>Container crash:</b> Compose <code>restart: unless-stopped</code>; api healthcheck failure
removes it from Caddy upstream until healthy.</li>
<li><b>Postgres unavailable:</b> api returns <code>503</code>; worker retries with backoff; no data loss (state is committed transactionally).</li>
<li><b>LiteLLM down:</b> all judge slots walk the fallback chain to stage 6; sessions go
<code>DEGRADED</code>/<code>FAILED</code> rather than hang; alert fires.</li>
<li><b>Wasabi unreachable:</b> ingest fails fast with <code>503</code>; nightly backup retries and alerts on repeated failure.</li>
</ul>
</section>
<section id="infra">
<h2><span class="n">10</span>Infrastructure Tracker</h2>
<p class="lead">Canonical record of the deployed footprint. The product domain is
<b>verdicttank.com</b>. <code>rfptank.com</code> is retained only as legacy/defensive and serves no
production traffic.</p>
<h3>Domains &amp; DNS</h3>
<table>
<tr><th>Host</th><th>Type</th><th>Target</th><th>Purpose</th></tr>
<tr><td><code>verdicttank.com</code></td><td>A</td><td>152.53.241.111</td><td>Primary web app + report viewer</td></tr>
<tr><td><code>www.verdicttank.com</code></td><td>CNAME</td><td>verdicttank.com</td><td>Canonical redirect → apex</td></tr>
<tr><td><code>api.verdicttank.com</code></td><td>A</td><td>152.53.241.111</td><td>REST API (<code>/v4</code>)</td></tr>
<tr><td><code>verdicttank.com</code></td><td>CAA</td><td>letsencrypt.org</td><td>Restrict cert issuance</td></tr>
<tr><td><code>verdicttank.com</code></td><td>MX / TXT (SPF, DMARC)</td><td>provider MX; v=spf1 …; DMARC p=quarantine</td><td>Transactional email deliverability</td></tr>
</table>
<h3>TLS</h3>
<ul>
<li>Certificates issued and auto-renewed by Caddy (ACME / Let's Encrypt) for
<code>verdicttank.com</code>, <code>www.verdicttank.com</code>, <code>api.verdicttank.com</code>.</li>
<li>TLS 1.3, HSTS <code>max-age=31536000; includeSubDomains</code>. CAA locks issuance to the ACME CA.</li>
</ul>
<h3>Origin &amp; Storage</h3>
<table>
<tr><th>Resource</th><th>Location</th></tr>
<tr><td>Origin host (app3)</td><td>netcup RS 4000 · <code>152.53.241.111</code></td></tr>
<tr><td>Deploy path (proposal/architecture pages)</td><td><code>/home/ippadmin/htdocs/proposals.itpropartner.com/verdicttank/</code></td></tr>
<tr><td>Artifact store</td><td><code>s3://vt-artifacts/</code> (Wasabi, SSE, versioned)</td></tr>
<tr><td>Corpus store</td><td><code>s3://vt-corpus/{tenant_id}/</code> (Wasabi, prefix-isolated)</td></tr>
<tr><td>Backup store</td><td><code>s3://vt-backups/pg/</code> (Wasabi, 30-day retention)</td></tr>
</table>
<h3>Backup Schedule</h3>
<table>
<tr><th>Asset</th><th>Method</th><th>Frequency</th><th>Retention</th></tr>
<tr><td>PostgreSQL</td><td><code>pg_dump</code> → Wasabi</td><td>Nightly</td><td>30 days</td></tr>
<tr><td>Artifacts / corpus</td><td>Wasabi versioning (durable by default)</td><td>Continuous</td><td>Per lifecycle policy</td></tr>
<tr><td>Config-as-code</td><td>Git (infra repo)</td><td>On change</td><td>Full history</td></tr>
</table>
<div class="legacy"><b>Legacy / defensive — rfptank.com.</b> <code>rfptank.com</code> was the product's
prior working name. It is held defensively to prevent squatting and to 301-redirect any inbound legacy
links to <code>verdicttank.com</code>. It runs <b>no</b> application, stores <b>no</b> data, and issues
<b>no</b> certificates for production services. Every production reference — API base URL, report URLs,
webhook origins, DNS, TLS, and object-store buckets — is namespaced to <code>verdicttank.com</code>.
If <code>rfptank.com</code> appears anywhere outside this note, treat it as a defect.</div>
<h3>Deployed Document Map</h3>
<table>
<tr><th>Document</th><th>URL</th></tr>
<tr><td>Proposal</td><td><code>https://proposals.itpropartner.com/verdicttank/index.html</code></td></tr>
<tr><td>Architecture (this doc)</td><td><code>https://proposals.itpropartner.com/verdicttank/architecture.html</code></td></tr>
<tr><td>Review (current)</td><td><code>https://proposals.itpropartner.com/verdicttank/review.html</code></td></tr>
<tr><td>Product app</td><td><code>https://verdicttank.com</code></td></tr>
<tr><td>API</td><td><code>https://api.verdicttank.com/v4</code></td></tr>
</table>
</section>
<!-- END SECTIONS -->
</main>
<footer>
<div class="wrap">
VerdictTank v4.0 Technical Architecture · verdicttank.com · Origin app3 (152.53.241.111) ·
Generated for implementation. All domain references are <code>verdicttank.com</code>;
<code>rfptank.com</code> appears only as documented legacy/defensive.
</div>
</footer>
</body>
</html>