1System Overview
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 PASS / BELOW decision with per-criterion breakdowns and actionable fixes.
The Consensus Problem
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 consensus problem, 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 reproducible, auditable aggregation of independent opinions.
Architecture Decisions
| # | Decision | Rationale |
|---|---|---|
| AD-01 | Three-judge heterogeneous panel via LiteLLM (Anthropic, OpenAI, DeepSeek families). | Different corpora and RLHF regimes decorrelate errors. Odd count avoids ties in criterion-level majority checks. |
| AD-02 | LiteLLM proxy as the single model gateway. No direct provider SDK calls from app code. | Uniform auth, per-key budgets, request logging, and hot-swappable model routing without redeploying the app. |
| AD-03 | Parallel judge dispatch with per-judge timeout, not sequential. | Wall-clock latency is bounded by the slowest judge, not the sum. Enables a strict end-to-end latency budget. |
| AD-04 | Binary verdict (PASS / BELOW) against one published threshold, not a letter grade. | A single decision boundary is auditable and defensible to a buyer. Numeric sub-scores are retained for detail. |
| AD-05 | 6-stage fallback chain for every judge slot. | Provider outages, rate limits, and malformed output must degrade gracefully to a still-valid verdict, never a hard failure. |
| AD-06 | PII sanitization before any model call (deterministic redaction pass). | No customer PII crosses a sub-processor boundary. Sanitization is idempotent and logged per session. |
| AD-07 | Per-tenant corpus isolation at the row and object-store prefix level. | One tenant's reference corpus can never leak into another's scoring context. Enforced in query layer and S3 prefix ACLs. |
| AD-08 | Sub-processor training guard: all provider calls set no-train / zero-retention flags where offered; DeepSeek routed through a no-retention endpoint. | Contractual and technical guarantee that customer content is never used to train third-party models. |
| AD-09 | Review session as an explicit state machine persisted in Postgres. | Every transition is durable and replayable. Crash recovery resumes from the last committed state, never re-charges a completed judge call. |
| AD-10 | Fix-It rescore delta is measured, not promised. Re-scoring after fixes produces a real before/after delta. | The product proves its own value: the customer sees the verdict actually move. |
| AD-11 | White-Label access gated by a 5-condition ledger returning HTTP 423 until all conditions clear. | Processor-role features cannot be enabled until legal, billing, and isolation prerequisites are verifiably satisfied. |
| AD-12 | Stateless app tier, stateful data tier. App containers hold no session state; Postgres + Redis + Wasabi S3 hold all durable state. | Horizontal scale and zero-downtime redeploys. Any app replica can serve any request. |
Component Map
┌──────────────────────────────────────────────────────────────────────────┐
│ 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.
2Multimodel Scoring Pipeline
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.
Judge Roster (via LiteLLM)
| Slot | Family | LiteLLM model alias | Role | Weight |
|---|---|---|---|---|
| A | Anthropic | judge-anthropic | Reasoning depth, requirement traceability | 1.0 |
| B | OpenAI | judge-openai | Structure, clarity, completeness | 1.0 |
| C | DeepSeek | judge-deepseek | Cost-anchor, adversarial skepticism | 1.0 |
Aliases resolve to concrete provider models inside LiteLLM's config.yaml.
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.
Parallel Dispatch
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)
Dispatch uses asyncio.gather(return_exceptions=True). A judge that raises or
times out does not abort the panel; its slot enters the fallback chain independently. The panel is
valid if at least 2 of 3 judges return a well-formed score (quorum = 2). With only 1 valid
judge the session transitions to DEGRADED and the verdict is flagged low-confidence.
Scoring Schema (judge output contract)
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).
{
"$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"
}
}
Aggregation
Aggregation is deterministic and published so a customer can reproduce the verdict by hand.
- Per-criterion aggregate. For criterion
c, compute the judge-weighted mean:agg_c = Σ(judge_weight_j × score_jc) / Σ(judge_weight_j)over judges that returned a valid score forc. - Criterion-weighted overall.
OVERALL = Σ(rubric_weight_c × agg_c) / Σ(rubric_weight_c). - Agreement metric.
spread_c = max_j(score_jc) − min_j(score_jc). Any criterion withspread_c ≥ 25is taggedcontestedand surfaced in the report. - Confidence.
confidence = clamp(1 − mean(spread_c)/100, 0, 1), degraded by 0.15 per missing judge below quorum. - Verdict.
OVERALL ≥ threshold → PASSelseBELOW. Defaultthreshold = 75; configurable per rubric, stored on the verdict for auditability.
0.2 and seed is set
where the provider supports it; raw judge outputs are persisted so a verdict is fully replayable.6-Stage Fallback Chain (per judge slot)
Each judge slot independently walks this chain. The chain guarantees a slot either yields a valid
score or is cleanly marked UNAVAILABLE (counting against quorum) — it never hangs.
| Stage | Trigger | Action | Budget |
|---|---|---|---|
| 1 · Primary | — | Call the slot's configured model through LiteLLM. | 8s |
| 2 · Retry | 5xx / network / timeout | Single retry with 500ms jitter backoff, same model. | +8s |
| 3 · Sibling model | 429 rate-limit or repeated 5xx | LiteLLM routes to the same family's alternate deployment (fallback map in config.yaml). | +8s |
| 4 · Reformat | 200 but invalid/non-JSON body | Re-prompt the model with the exact schema and the offending output, demanding valid JSON only. | +6s |
| 5 · Cross-family substitute | Family fully unavailable | Borrow a spare deployment from another family, tagged substituted in the verdict (weight halved). | +8s |
| 6 · Mark unavailable | All above exhausted | Slot → UNAVAILABLE. Panel proceeds on remaining judges if quorum (≥2) holds; else session → DEGRADED. | 0s |
Latency Budgets
| Path | Target (p50) | Ceiling (p95) | Notes |
|---|---|---|---|
| Ingest + sanitize | 400 ms | 1.2 s | Deterministic redaction; scales with artifact size. |
| Parallel judge panel (happy path) | 4.5 s | 8 s | Bounded by slowest judge, not the sum. Per-judge timeout 8s. |
| Judge panel with one fallback hop | — | 16 s | Stage 2/3 adds one budget window to the affected slot only. |
| Aggregation + persist | 60 ms | 200 ms | Pure CPU + one DB write. |
| End-to-end (submit → verdict) | ~5 s | 18 s | Hard wall-clock cap at 25 s → session marked TIMEOUT, partial verdict if quorum met. |
| Fix-It rescore (single criterion) | 3 s | 9 s | Re-runs only affected criteria across the panel when possible. |
Error handling — pipeline
- All judges fail (quorum lost): session →
FAILED; API returns503withRetry-After; no charge recorded against the session's review credit. - Partial panel (2 of 3): verdict produced,
confidencereduced, response includes"degraded": trueand the unavailable slot id. - Malformed after reformat (stage 4 fails): slot treated as
UNAVAILABLE; raw body stored for post-mortem injudge_raw_output. - Wall-clock cap hit: in-flight judge calls are cancelled; if quorum already met the
partial verdict is committed as
TIMEOUT_PARTIAL, elseFAILED.
3Fix-It Engine
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.
Failure Modes (what Fix-It detects)
| Mode | Detection signal | Example fix |
|---|---|---|
MISSING_REQUIREMENT | Criterion req_coverage score < 60 or judge flags contains a requirement id. | "RFP §4.2 asks for SOC 2 evidence — no security section addresses it. Add a compliance subsection citing your SOC 2 report." |
WEAK_EVIDENCE | High-weight criterion scored mid-range with judge rationale citing "unsupported" / "no data". | "The ROI claim has no figures. Add a quantified savings table." |
STRUCTURAL | structure criterion low; ordering/section flags raised. | "Executive summary appears after pricing. Move it to the front." |
CLARITY | clarity low; long-sentence / jargon flags. | "Section 3 averages 40-word sentences. Break into shorter statements." |
CONTESTED | spread_c ≥ 25 (judges disagree). | "Judges split on differentiation. Make the unique-value claim explicit and defensible." |
COMPLIANCE_GAP | Mandatory rubric criterion below its hard floor. | "Insurance certificate is required and absent. This alone forces a BELOW verdict." |
Prioritization Formula
Fixes are ranked by expected verdict impact per unit of effort, so the customer fixes the things that move the needle first.
where
gap_c = max(0, target_score − agg_c) // distance to a passing sub-score, target default 80
contested_bonus_c = 1 + (spread_c ≥ 25 ? 0.5 : 0) // disagreement is high-value to resolve
effort_c = { STRUCTURAL:1, CLARITY:1, WEAK_EVIDENCE:2, MISSING_REQUIREMENT:3, COMPLIANCE_GAP:3 }
Fixes are returned sorted by priority_c descending. Each carries an
estimated_lift = rubric_weight_c × gap_c — the maximum points the overall
score can gain if that criterion reaches target_score. The UI shows a running
"projected verdict if all applied" sum, clamped so OVERALL ≤ 100.
Rescore Delta
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.
{
"$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"
}
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.
Single-Tier Binary Verdict — PASS / BELOW
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.
OVERALL ≥ threshold AND every mandatory criterion is at
or above its hard floor. The artifact meets the bar defined by the rubric.
OVERALL < threshold OR any mandatory criterion is under
its hard floor (a single compliance gap forces BELOW regardless of overall score).
The numeric OVERALL, 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.
Error handling — Fix-It
- No weak criteria (already PASS): engine returns an empty fix list with
"status":"no_action_needed"; UI shows the passing report only. - Rescore panel loses quorum: the
beforeverdict is preserved unchanged; response carries"rescore_status":"degraded"and does not overwrite the stored verdict. - Artifact unchanged from prior submission (hash match): rescore is skipped;
returns
409 Conflictwith the existing verdict to avoid a redundant paid run. - Compliance floor still breached after fixes: verdict stays
BELOWeven ifOVERALLcrosses threshold; the blocking criterion is named inblocking_criteria.
4Data Flow
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.
6-Stage Pipeline
①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
① INGEST — input contract
{ "$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" } }
Parser extracts plain text (PDF/DOCX/MD/TXT). Artifact stored to Wasabi at
s3://vt-artifacts/{tenant_id}/{session_id}/original. Output: artifact_text
+ token_count.
② SANITIZE — output contract
{ "$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..." }
Deterministic regex + NER redaction. Idempotent: re-running yields the identical
sanitize_hash. Only the sanitized text is ever sent to a judge (AD-06).
③ DISPATCH — output contract (per judge, see §2 scoring schema)
{ "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 }
④ AGGREGATE — output contract
{ "$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" }
⑤ FIXIT — output contract (see §3)
{ "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 }
⑥ PERSIST / DELIVER — output contract
{ "session_id":"rs_9f3a", "state":"COMPLETED",
"report_url":"https://verdicttank.com/r/rs_9f3a",
"webhook_delivered":true, "verdict":"BELOW" }
Review Session State Machine
┌───────────────────────────────────────────────┐
│ │
(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.
| State | Meaning | Terminal? |
|---|---|---|
RECEIVED | Upload accepted, parse pending. | no |
SANITIZED | PII redacted, ready for judges. | no |
DISPATCHING | Judge panel in flight. | no |
AGGREGATED | Scores combined, verdict pending write. | no |
SCORED | Verdict committed, report available. | no |
FIXIT_READY | Fixes generated, awaiting revised artifact. | no |
RESCORING | Revised artifact re-scoring. | no |
DEGRADED | Verdict from <quorum judges; low confidence. | no |
COMPLETED | Delivered (webhook + report). | yes |
TIMEOUT_PARTIAL | Wall-clock cap hit; partial verdict committed. | yes* |
FAILED | Quorum never met; no charge. | yes |
*TIMEOUT_PARTIAL is terminal for the original run but a new rescore may
reopen the session into RESCORING.
5API Surface
REST/JSON over HTTPS. Base URL https://api.verdicttank.com/v4. All bodies
UTF-8 JSON. All timestamps RFC 3339 UTC. All IDs are prefixed opaque strings.
Authentication
- API keys —
Authorization: Bearer vt_live_XXXXfor server-to-server. Keys are tenant-scoped, hashed at rest (argon2id), and carry a role (viewer,reviewer,admin). Test keys prefixedvt_test_. - Session JWT — short-lived (15 min) bearer for browser clients, issued by
POST /auth/token, refreshed viaPOST /auth/refresh. Containstenant_id,role,exp. Signed HS256 with a rotated secret. - Webhook signature — outbound webhooks are signed
X-VT-Signature: sha256=HMAC(body)with the tenant's webhook secret.
Rate Limiting
Token-bucket per API key, enforced in Redis. Limits returned on every response:
X-RateLimit-Limit: 60 X-RateLimit-Remaining: 57 X-RateLimit-Reset: 1754923200 Retry-After: 12 # only on 429
| Plan | Reviews/min | Reviews/day | Burst |
|---|---|---|---|
| Free | 2 | 10 | 3 |
| Pro ($79/mo) | 20 | 500 (fair-use) | 30 |
| Enterprise ($299/mo) | 60 | negotiated | 100 |
Endpoints
- POST
/auth/token— exchange API key or credentials for a session JWT. - POST
/reviews— create a review (ingest artifact, start pipeline). - GET
/reviews/{id}— fetch a review session + verdict. - GET
/reviews/{id}/verdict— verdict + per-criterion detail only. - GET
/reviews/{id}/fixes— Fix-It ranked fix list. - POST
/reviews/{id}/rescore— submit revised artifact, get before/after delta. - GET
/reviews— list tenant reviews (paginated, filterable by verdict/state). - GET
/rubrics· POST/rubrics— manage scoring rubrics. - GET
/webhooks· POST/webhooks· DELETE/webhooks/{id}— manage webhook endpoints. - GET
/whitelabel/status— 5-condition gate ledger (see §8). - GET
/healthz· GET/metrics— liveness + Prometheus.
POST /reviews — request
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 } }
POST /reviews — response (202 async)
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 }
With "async":false the call blocks up to the 25 s wall-clock cap and
returns 200 with the full verdict inline.
GET /reviews/{id} — response (200)
{ "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" }
POST /reviews/{id}/rescore — request/response
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" }
Webhooks
Registered endpoints receive signed POSTs on state transitions. Events: review.scored,
review.completed, review.failed, review.rescored.
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" }
Delivery: at-least-once with exponential backoff (max 6 attempts over ~1h). Consumers
must be idempotent on X-VT-Delivery. Verify by recomputing
HMAC-SHA256(secret, raw_body) and constant-time comparing to X-VT-Signature.
Error Model
All errors share one envelope. HTTP status conveys the class; error conveys the code.
{ "error":"quorum_lost",
"message":"Only 1 of 3 judges returned a valid score.",
"session_id":"rs_9f3a", "request_id":"req_7c2e", "retryable":true }
| HTTP | error code | Meaning | Retryable |
|---|---|---|---|
| 400 | invalid_request | Malformed body / unsupported mime / missing field. | no |
| 401 | unauthenticated | Missing/invalid API key or expired JWT. | no |
| 403 | forbidden | Role lacks permission (e.g. viewer creating a review). | no |
| 404 | not_found | Unknown session/rubric/webhook id (or cross-tenant access). | no |
| 409 | unchanged_artifact | Rescore artifact identical to prior (hash match). | no |
| 413 | artifact_too_large | Artifact exceeds size/token cap. | no |
| 422 | unprocessable_artifact | Parse failed (corrupt PDF, empty text). | no |
| 423 | whitelabel_locked | White-Label gate conditions unmet (see §8). | no |
| 429 | rate_limited | Bucket exhausted; see Retry-After. | yes |
| 503 | quorum_lost | Judge panel failed to reach quorum; no charge recorded. | yes |
| 504 | pipeline_timeout | Wall-clock cap hit with no partial verdict. | yes |
6Data Model
PostgreSQL 16. Multi-tenant with tenant_id on every business table and
row-level security enforced by the query layer. All money is integer cents; all scores are numeric(5,2).
Entity Relationships
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
DDL
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()
);
Row-level security policies (omitted for brevity) restrict every business table to
tenant_id = current_setting('app.tenant_id'). The app sets this GUC per request from the
authenticated key, so a query can never span tenants even on a coding error.
7Security
Customer proposals are confidential business documents. The security model assumes every judge is an external sub-processor and every tenant is mutually distrustful.
PII Sanitization
Sanitization (data-flow stage ②) runs before any bytes leave the trust boundary for a model call. It is deterministic and idempotent so a verdict is reproducible and the redaction is auditable.
- Detectors: 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.
- Replacement: each match becomes a typed token
[REDACTED:EMAIL]. A per-session salted map (never sent to judges) allows post-hoc rehydration in the customer's own report view only. - Idempotency:
sanitize_hash = sha256(sanitized_text)is the judge-cache key — re-scoring identical sanitized text reuses cached judge results and never re-bills. - Audit: the
redactionscount vector (no raw values) is stored on the session.
FAILED rather than dispatching unsanitized text. Redaction is
never skipped to "save the run."Corpus Isolation
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):
- Storage: S3 prefix
s3://vt-corpus/{tenant_id}/…with a bucket policy denying cross-prefix reads; each tenant's IAM-scoped credentials cannot list outside their prefix. - Database: row-level security keyed on
tenant_idGUC (see §6). - Retrieval: corpus lookups always inject
WHERE tenant_id = :current; the query builder refuses to run a corpus query without a bound tenant.
Sub-Processor Training Guard
Customer content must never train a third-party model (AD-08). Enforced at the LiteLLM boundary:
- Every judge call sets provider no-train / zero-data-retention flags where offered
(e.g. OpenAI
store:false, Anthropic no-retention headers). - DeepSeek traffic is routed only through a contracted no-retention endpoint; if that route is
unavailable, the DeepSeek slot is marked
UNAVAILABLErather than falling back to a retaining endpoint — the training guard overrides the fallback chain. - LiteLLM request logs store metadata + token counts only, never artifact text.
- Contractual: each provider is a named sub-processor in the DPA with a no-training clause; the
tenant's
subprocessor_ackledger flag records acknowledgment (see §8).
Encryption
| Layer | Control |
|---|---|
| In transit (edge) | TLS 1.3 via Caddy; HSTS; automatic cert renewal. |
| In transit (internal) | App↔Postgres, App↔Redis, App↔LiteLLM on the Docker private network; no plaintext service exposed to the host public interface. |
| At rest (DB) | Postgres volume on encrypted disk; secrets columns (key_hash, secret_hash) are one-way hashed (argon2id), never reversible. |
| At rest (object store) | Wasabi S3 server-side encryption (SSE) on all objects; artifact objects lifecycle-expired after retention window. |
| Secrets | Provider keys and JWT signing secret injected via environment from a root-only .env (chmod 600), never committed; rotated on a schedule. |
RBAC
| Role | Read verdicts | Create/rescore reviews | Manage keys/webhooks/rubrics | White-Label admin |
|---|---|---|---|---|
viewer | ✔ | ✗ | ✗ | ✗ |
reviewer | ✔ | ✔ | ✗ | ✗ |
admin | ✔ | ✔ | ✔ | ✔ (subject to §8 gate) |
Roles are carried on the API key and in the session JWT. Every mutating endpoint
checks role before touching state; a role violation returns 403 forbidden. Cross-tenant
access (valid key, wrong tenant's resource id) returns 404 not_found — the platform does
not reveal that another tenant's resource exists.
Error handling — security
- Sanitizer failure: fail-closed → session
FAILED, no dispatch, alert fired. - Training-guard route down: affected judge slot
UNAVAILABLE; guard never bypassed. - RLS/GUC unset: queries against business tables raise and abort the request (defense in depth against a missing tenant context).
- Expired/rotated JWT secret: old tokens fail verification →
401; clients refresh via/auth/refresh.
8White-Label Gate
White-Label makes VerdictTank a processor 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 HTTP 423 Locked.
5-Condition Ledger
Backed by the whitelabel_ledger table (§6). All five booleans must be true
before unlocked_at is stamped and White-Label endpoints activate.
| # | Ledger flag | Condition | Owner / evidence |
|---|---|---|---|
| 1 | dpa_signed | Data Processing Agreement executed (processor role, no-training sub-processor clause). | Legal — signed DPA on file. |
| 2 | billing_active | White-Label plan billing in good standing (no failed/overdue invoice). | Billing — active subscription record. |
| 3 | corpus_isolated | Dedicated S3 corpus prefix + RLS partition provisioned and verified for the tenant. | Platform — isolation probe passes. |
| 4 | branding_approved | Custom logo, domain, and report styling reviewed and approved (no trademark conflict). | Ops — branding checklist signed off. |
| 5 | subprocessor_ack | Tenant has acknowledged the current sub-processor list (Anthropic, OpenAI, DeepSeek) and no-training guarantee. | Legal/tenant — acknowledgment timestamp. |
unlocked = dpa_signed AND billing_active AND corpus_isolated AND branding_approved AND subprocessor_ack.
On the transition to all-true the platform sets unlocked_at = now() in a single
transaction. If any flag later flips to false (e.g. billing lapses), White-Label
endpoints immediately re-lock and return 423 again — the gate is evaluated on every request, not cached.GET /whitelabel/status — response (200)
{ "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"] }
HTTP 423 Response (locked feature access)
Any White-Label-scoped endpoint (custom-branded report render, processor-mode review, tenant-branded webhook) invoked while the ledger is incomplete returns:
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 }
423 is chosen deliberately over 403: the resource is not forbidden by role, it is
temporarily locked pending a state change the tenant can resolve. The blocking
array tells the caller exactly which conditions remain, and status_url points at the
live ledger. Once all conditions clear, the same call succeeds with no code change on the client side.
9Deployment Topology
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.
Host
| Attribute | Value |
|---|---|
| Provider / plan | netcup RS 4000 (root server) |
| Origin (app3) | 152.53.241.111 |
| Orchestration | Docker Compose (single-host, multi-container) |
| Edge | Caddy 2 — automatic TLS, HTTP/2, reverse proxy, per-route rate limit |
| Object store | Wasabi S3 (vt-artifacts, vt-corpus, vt-backups) |
| Observability | Prometheus (scrape) + Grafana (dashboards + alerts) |
Topology Diagram
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
└─────────────────────────────────────────────────────────────────────────────────┘
docker-compose.yml (reference)
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: {} }
Caddyfile (reference)
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
}
Observability
- Prometheus scrapes
/metricsfrom api, worker, and litellm every 15 s. - Key metrics:
vt_review_latency_seconds(histogram),vt_judge_fallback_stage(counter by stage),vt_quorum_lost_total,vt_verdict_total{verdict},vt_sanitize_failures_total. - Grafana alerts: p95 end-to-end > 18 s (5 min), quorum-loss rate > 1% (10 min), any sanitizer failure (immediate), a judge slot stuck at fallback stage ≥5 (5 min).
Backups & DR
- Nightly
pg_dump→s3://vt-backups/pg/(Wasabi), 30-day retention. - Artifact + corpus objects already durable in Wasabi (SSE); versioning enabled on
vt-artifacts. - Config-as-code: compose file, Caddyfile, litellm
config.yaml, and DDL migrations in the infra repo — full host rebuild from repo +.env+ latest pg dump.
Error handling — deployment
- Container crash: Compose
restart: unless-stopped; api healthcheck failure removes it from Caddy upstream until healthy. - Postgres unavailable: api returns
503; worker retries with backoff; no data loss (state is committed transactionally). - LiteLLM down: all judge slots walk the fallback chain to stage 6; sessions go
DEGRADED/FAILEDrather than hang; alert fires. - Wasabi unreachable: ingest fails fast with
503; nightly backup retries and alerts on repeated failure.
10Infrastructure Tracker
Canonical record of the deployed footprint. The product domain is
verdicttank.com. rfptank.com is retained only as legacy/defensive and serves no
production traffic.
Domains & DNS
| Host | Type | Target | Purpose |
|---|---|---|---|
verdicttank.com | A | 152.53.241.111 | Primary web app + report viewer |
www.verdicttank.com | CNAME | verdicttank.com | Canonical redirect → apex |
api.verdicttank.com | A | 152.53.241.111 | REST API (/v4) |
verdicttank.com | CAA | letsencrypt.org | Restrict cert issuance |
verdicttank.com | MX / TXT (SPF, DMARC) | provider MX; v=spf1 …; DMARC p=quarantine | Transactional email deliverability |
TLS
- Certificates issued and auto-renewed by Caddy (ACME / Let's Encrypt) for
verdicttank.com,www.verdicttank.com,api.verdicttank.com. - TLS 1.3, HSTS
max-age=31536000; includeSubDomains. CAA locks issuance to the ACME CA.
Origin & Storage
| Resource | Location |
|---|---|
| Origin host (app3) | netcup RS 4000 · 152.53.241.111 |
| Deploy path (proposal/architecture pages) | /home/ippadmin/htdocs/proposals.itpropartner.com/verdicttank/ |
| Artifact store | s3://vt-artifacts/ (Wasabi, SSE, versioned) |
| Corpus store | s3://vt-corpus/{tenant_id}/ (Wasabi, prefix-isolated) |
| Backup store | s3://vt-backups/pg/ (Wasabi, 30-day retention) |
Backup Schedule
| Asset | Method | Frequency | Retention |
|---|---|---|---|
| PostgreSQL | pg_dump → Wasabi | Nightly | 30 days |
| Artifacts / corpus | Wasabi versioning (durable by default) | Continuous | Per lifecycle policy |
| Config-as-code | Git (infra repo) | On change | Full history |
rfptank.com was the product's
prior working name. It is held defensively to prevent squatting and to 301-redirect any inbound legacy
links to verdicttank.com. It runs no application, stores no data, and issues
no certificates for production services. Every production reference — API base URL, report URLs,
webhook origins, DNS, TLS, and object-store buckets — is namespaced to verdicttank.com.
If rfptank.com appears anywhere outside this note, treat it as a defect.Deployed Document Map
| Document | URL |
|---|---|
| Proposal | https://proposals.itpropartner.com/verdicttank/index.html |
| Architecture (this doc) | https://proposals.itpropartner.com/verdicttank/architecture.html |
| Review (current) | https://proposals.itpropartner.com/verdicttank/review.html |
| Product app | https://verdicttank.com |
| API | https://api.verdicttank.com/v4 |