Technical Architecture · v4.0

VerdictTank — Multimodel Proposal Review Platform

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.

Version v4.0 Domain verdicttank.com Status Implementation-Ready Origin netcup RS 4000 (app3) Judges 3 via LiteLLM Verdict Binary PASS / BELOW

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.

Design thesis. 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.

Architecture Decisions

#DecisionRationale
AD-01Three-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-02LiteLLM 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-03Parallel 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-04Binary 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-056-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-06PII sanitization before any model call (deterministic redaction pass). No customer PII crosses a sub-processor boundary. Sanitization is idempotent and logged per session.
AD-07Per-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-08Sub-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-09Review 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-10Fix-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-11White-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-12Stateless 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.
  
Tech stack. 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.

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)

SlotFamilyLiteLLM model aliasRoleWeight
AAnthropicjudge-anthropicReasoning depth, requirement traceability1.0
BOpenAIjudge-openaiStructure, clarity, completeness1.0
CDeepSeekjudge-deepseekCost-anchor, adversarial skepticism1.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.

  1. 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 for c.
  2. Criterion-weighted overall. OVERALL = Σ(rubric_weight_c × agg_c) / Σ(rubric_weight_c).
  3. Agreement metric. spread_c = max_j(score_jc) − min_j(score_jc). Any criterion with spread_c ≥ 25 is tagged contested and surfaced in the report.
  4. Confidence. confidence = clamp(1 − mean(spread_c)/100, 0, 1), degraded by 0.15 per missing judge below quorum.
  5. Verdict. OVERALL ≥ threshold → PASS else BELOW. Default threshold = 75; configurable per rubric, stored on the verdict for auditability.
Determinism guarantee. 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 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.

StageTriggerActionBudget
1 · PrimaryCall the slot's configured model through LiteLLM.8s
2 · Retry5xx / network / timeoutSingle retry with 500ms jitter backoff, same model.+8s
3 · Sibling model429 rate-limit or repeated 5xxLiteLLM routes to the same family's alternate deployment (fallback map in config.yaml).+8s
4 · Reformat200 but invalid/non-JSON bodyRe-prompt the model with the exact schema and the offending output, demanding valid JSON only.+6s
5 · Cross-family substituteFamily fully unavailableBorrow a spare deployment from another family, tagged substituted in the verdict (weight halved).+8s
6 · Mark unavailableAll above exhaustedSlot → UNAVAILABLE. Panel proceeds on remaining judges if quorum (≥2) holds; else session → DEGRADED.0s

Latency Budgets

PathTarget (p50)Ceiling (p95)Notes
Ingest + sanitize400 ms1.2 sDeterministic redaction; scales with artifact size.
Parallel judge panel (happy path)4.5 s8 sBounded by slowest judge, not the sum. Per-judge timeout 8s.
Judge panel with one fallback hop16 sStage 2/3 adds one budget window to the affected slot only.
Aggregation + persist60 ms200 msPure CPU + one DB write.
End-to-end (submit → verdict)~5 s18 sHard wall-clock cap at 25 s → session marked TIMEOUT, partial verdict if quorum met.
Fix-It rescore (single criterion)3 s9 sRe-runs only affected criteria across the panel when possible.

Error handling — pipeline

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)

ModeDetection signalExample fix
MISSING_REQUIREMENTCriterion 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_EVIDENCEHigh-weight criterion scored mid-range with judge rationale citing "unsupported" / "no data"."The ROI claim has no figures. Add a quantified savings table."
STRUCTURALstructure criterion low; ordering/section flags raised."Executive summary appears after pricing. Move it to the front."
CLARITYclarity low; long-sentence / jargon flags."Section 3 averages 40-word sentences. Break into shorter statements."
CONTESTEDspread_c ≥ 25 (judges disagree)."Judges split on differentiation. Make the unique-value claim explicit and defensible."
COMPLIANCE_GAPMandatory 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.

priority_c = ( rubric_weight_c × gap_c × contested_bonus_c ) / effort_c

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.

PASS

OVERALL ≥ threshold AND every mandatory criterion is at or above its hard floor. The artifact meets the bar defined by the rubric.

BELOW

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

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.
  
StateMeaningTerminal?
RECEIVEDUpload accepted, parse pending.no
SANITIZEDPII redacted, ready for judges.no
DISPATCHINGJudge panel in flight.no
AGGREGATEDScores combined, verdict pending write.no
SCOREDVerdict committed, report available.no
FIXIT_READYFixes generated, awaiting revised artifact.no
RESCORINGRevised artifact re-scoring.no
DEGRADEDVerdict from <quorum judges; low confidence.no
COMPLETEDDelivered (webhook + report).yes
TIMEOUT_PARTIALWall-clock cap hit; partial verdict committed.yes*
FAILEDQuorum 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

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
PlanReviews/minReviews/dayBurst
Free2103
Pro ($79/mo)20500 (fair-use)30
Enterprise ($299/mo)60negotiated100

Endpoints

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 }
HTTPerror codeMeaningRetryable
400invalid_requestMalformed body / unsupported mime / missing field.no
401unauthenticatedMissing/invalid API key or expired JWT.no
403forbiddenRole lacks permission (e.g. viewer creating a review).no
404not_foundUnknown session/rubric/webhook id (or cross-tenant access).no
409unchanged_artifactRescore artifact identical to prior (hash match).no
413artifact_too_largeArtifact exceeds size/token cap.no
422unprocessable_artifactParse failed (corrupt PDF, empty text).no
423whitelabel_lockedWhite-Label gate conditions unmet (see §8).no
429rate_limitedBucket exhausted; see Retry-After.yes
503quorum_lostJudge panel failed to reach quorum; no charge recorded.yes
504pipeline_timeoutWall-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.

Fail-closed. If the sanitizer errors or a detector library fails to load, the session transitions to 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):

Sub-Processor Training Guard

Customer content must never train a third-party model (AD-08). Enforced at the LiteLLM boundary:

Encryption

LayerControl
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.
SecretsProvider keys and JWT signing secret injected via environment from a root-only .env (chmod 600), never committed; rotated on a schedule.

RBAC

RoleRead verdictsCreate/rescore reviewsManage keys/webhooks/rubricsWhite-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

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 flagConditionOwner / evidence
1dpa_signedData Processing Agreement executed (processor role, no-training sub-processor clause).Legal — signed DPA on file.
2billing_activeWhite-Label plan billing in good standing (no failed/overdue invoice).Billing — active subscription record.
3corpus_isolatedDedicated S3 corpus prefix + RLS partition provisioned and verified for the tenant.Platform — isolation probe passes.
4branding_approvedCustom logo, domain, and report styling reviewed and approved (no trademark conflict).Ops — branding checklist signed off.
5subprocessor_ackTenant has acknowledged the current sub-processor list (Anthropic, OpenAI, DeepSeek) and no-training guarantee.Legal/tenant — acknowledgment timestamp.
Unlock rule. 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

AttributeValue
Provider / plannetcup RS 4000 (root server)
Origin (app3)152.53.241.111
OrchestrationDocker Compose (single-host, multi-container)
EdgeCaddy 2 — automatic TLS, HTTP/2, reverse proxy, per-route rate limit
Object storeWasabi S3 (vt-artifacts, vt-corpus, vt-backups)
ObservabilityPrometheus (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

Backups & DR

Error handling — deployment

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

HostTypeTargetPurpose
verdicttank.comA152.53.241.111Primary web app + report viewer
www.verdicttank.comCNAMEverdicttank.comCanonical redirect → apex
api.verdicttank.comA152.53.241.111REST API (/v4)
verdicttank.comCAAletsencrypt.orgRestrict cert issuance
verdicttank.comMX / TXT (SPF, DMARC)provider MX; v=spf1 …; DMARC p=quarantineTransactional email deliverability

TLS

Origin & Storage

ResourceLocation
Origin host (app3)netcup RS 4000 · 152.53.241.111
Deploy path (proposal/architecture pages)/home/ippadmin/htdocs/proposals.itpropartner.com/verdicttank/
Artifact stores3://vt-artifacts/ (Wasabi, SSE, versioned)
Corpus stores3://vt-corpus/{tenant_id}/ (Wasabi, prefix-isolated)
Backup stores3://vt-backups/pg/ (Wasabi, 30-day retention)

Backup Schedule

AssetMethodFrequencyRetention
PostgreSQLpg_dump → WasabiNightly30 days
Artifacts / corpusWasabi versioning (durable by default)ContinuousPer lifecycle policy
Config-as-codeGit (infra repo)On changeFull history
Legacy / defensive — rfptank.com. 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

DocumentURL
Proposalhttps://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 apphttps://verdicttank.com
APIhttps://api.verdicttank.com/v4