← Virtual JuryAll Things Agentic Hackathon · The Fortified Enterprise Fleet · source (Apache-2.0)

How Virtual Jury works

An architectural walkthrough for judges: where Google ADK sits, what the model decides versus what code enforces, how twelve isolated jurors run on a pooled Google Kubernetes Engine fleet, and how every number in a findings report traces back to a tool call.

Google ADK (TypeScript)Gemini 3.7 Flash · Vertex AIModel ArmorGKECloud RunFirestoreBigQuerySecret Manager
In five sentences.
  1. Counsel submits a JurySpec — a case packet, a panel (size + seed), variants ("without exhibit 2"), and a verdict rule. Model Armor screens the packet before any model sees it.
  2. An ADK agent, the Clerk of Court, runs the procedure through six FunctionTools that enforce it in code: no deliberation before sealed verdicts, max rounds, idempotent provisioning.
  3. Each juror is an isolated Roundtable workspace — a registry row on an always-warm pooled service on GKE, with its own persona, row-level-secured state, and a tenant-bound HMAC identity. Provisioning a juror takes ~300 ms; no containers are created.
  4. Every juror seals a verdict before hearing anyone else; then each hears an anonymized digest of the others and restates a position. Code parses positions, counts votes, detects holdouts and convergence, and applies the verdict rule: plaintiff, defendant, or hung.
  5. Baseline and variants run as parallel panels; the Clerk writes the findings report from tool results only; every juror input/output is hashed into a provenance log, persisted in Firestore, and traced in BigQuery.

1. System

CounselJurySpec · UI or API Cloud Run · virtual-jury Run APIExpress · SSE · limits Clerk of CourtADK LlmAgent Procedure tools (FunctionTools)provision · seal · round · status · diff · variants Model Armortemplate vj-case-intake Vertex AIGemini 3.7 Flash (global) Roundtable control plane · Agent RegistryPOST /api/workspaces · blueprint virtual-jury-jurorregistry row: system_prompt (persona) · model · runtime=pooled GKE · pooled-juror service (one deployment, N tenants) J1 J2 J3 J12 panel B… PostgreSQL row-level security per tenant · persona = registry system_prompt A2A message/send · tenant-bound HMAC (workspace id inside the signed string) Firestoreruns · events · panels BigQueryrequest_traces → view Secret ManagerAPI key · HMAC secret Cloud Monitoring / Loggingdashboard · orchestrator logs screen Clerk reasoning (ADK → Gemini) provision A2A calls juror reasoning events · panels traces /api/metrics
Blue arrows are model calls; dashed arrows are telemetry. The orchestrator never talks to a juror's database — only to the juror, over A2A, as a bound tenant.
Fortified Enterprise Fleet componentImplementationWhere
Agent RegistryEach juror is a registered pooled workspace: POST /api/workspaces with template: virtual-jury-juror, the persona as systemPrompt, model: gemini-3.7-flash, runtime: pooled. The control plane writes a registry row; the fleet reads it per request. ~300 ms, no Kubernetes objects.api/services/roundtable.ts · api/jury/panel.ts
Runtime / Memory BankRuntime: one always-warm pooled-juror service (GKE, rendered from Roundtable's pooled template, plugin-free core image); tenant state isolated by PostgreSQL row-level security; the orchestrator keeps executing after the API's 202. Memory Bank — the decisions: every sealed verdict with its reasons and doubts, every deliberation statement and position change per round, holdouts, the outcome under the verdict rule, the Clerk's report, provenance hashes, and the trace id persist in Firestore per run; any run reopens with its event stream replayed, and runs of the same case accumulate under a canonical caseSha across sessions and days — the UI's Prior runs of this case card lists them with every panel's sealed → final split and outcome. The context a juror receives on each call is assembled from that store. Jurors themselves are stateless between panels by design — that is the isolation guarantee that keeps the memory trustworthy.api/store/runStore.ts · README § fleet
Identity / Gateway / Model ArmorTenant-bound S2S HMAC on every A2A call — the signed string is a2a:{timestamp}:{workspaceId}, so a signature minted for juror A cannot address juror B. Model Armor sanitizeUserPrompt on every packet and every intake text. Rate limits and size caps on the API.roundtable.ts · modelArmor.ts · server.ts
ObservabilityOpenTelemetry → Cloud Trace: one trace per run (jury.run) containing a span for every Clerk tool call (clerk.tool.*, with the structured result as attributes — the reasoning chain), every juror call (juror.seal / juror.round with verdict, confidence, changed), panel provisioning, and the Model Armor screen. The trace link is emitted into the run's event stream and shown in the UI. Plus: every fleet-side juror call is a traced span in BigQuery (/api/metrics, telemetry card); per-run provenance (SHA-256 of every juror input/output); Cloud Monitoring dashboard.metrics.ts · deliberation.ts

2. Google ADK: the Clerk of Court

The orchestrator is a single ADK LlmAgentthe Clerk of Court — running Gemini 3.1 Pro on Vertex AI's global endpoint (the jurors run Gemini 3.7 Flash: pro-tier judgment for the one agent that narrates findings, flash for the fleet where parallel volume matters). It is built per run with six tools bound to that run's state, then driven by InMemoryRunner.runEphemeral() with one user message: "Run the jury procedure for this case and write the findings report." The agent's final response is the report.

ADK LlmAgent · clerk_of_court model: gemini-3.1-pro-preview (Vertex, global) instruction: CLERK_INSTRUCTION (procedure + report spec) tools: 6 × FunctionTool (zod-typed) The model decideswhich tool next · when to stop · how to narrate findings The model never decidesvote counts · splits · holdouts · convergence · outcomes InMemoryRunner.runEphemeral({ userId, newMessage }) → AsyncGenerator<Event> · isFinalResponse(event) → report Observed per run: 5–8 model turns, 72–108 tool-driven juror calls list_variantsspec → variant names, panel size, max rounds provision_panel(variant)creates the panel's juror tenants · idempotent per name seal_verdicts(panel)identical packet to every juror · no cross-talk run_deliberation_round(panel)THROWS unless sealed · respects max rounds · outcome panel_status(panel)split · holdouts · converged · outcome under the rule diff_panels(variant)vs baseline · sealed/final splits · Δ plaintiff Roundtable juror fleet control plane: create workspace A2A message/send × N jurors (concurrency 6 · jittered backoff) each juror → Gemini 3.7 Flash returns JSON verdict / POSITION line code parses · tallies · hashes → tool result (numbers) → model structured results (numbers) back to the agent
The red tool is the procedure's guard: it refuses to run before seal_verdicts, so no instruction-following failure can leak one juror's view to another before commitments are recorded.

How the agent is built

// api/orchestrator/clerkAgent.ts
const agent = new LlmAgent({
  name: 'clerk_of_court',
  model: 'gemini-3.1-pro-preview', // Vertex AI, location 'global'
  instruction: CLERK_INSTRUCTION, // procedure + report format
  tools: buildClerkTools(state).map(toFunctionTool),
});
const runner = new InMemoryRunner({ agent, appName: 'virtual-jury' });
for await (const event of runner.runEphemeral({
  userId: 'counsel',
  newMessage: { parts: [{ text: 'Run the jury procedure…' }] },
})) if (isFinalResponse(event)) report += text(event);

Each tool is a plain { name, description, parameters (zod), execute } factory bound to the run's state, then wrapped in ADK's FunctionTool. The factories are unit-tested with fake jurors and no pods — the procedure is verified without a model in the loop.

What the tools enforce

// api/orchestrator/tools.ts (excerpt)
execute: async ({ panel }) => {
  const ps = needPanel(panel);               // unknown panel → throws
  if (!ps.sealed) throw new Error(
    'Verdicts must be sealed before deliberation');
  if (ps.rounds.length >= spec.rounds)
    return { ok: false, reason: 'max rounds reached' };
  const r = await runRound(ps.panel, ps.sealed, ps.rounds, …);
  const outcome = panelOutcome(r.split, spec.verdictRule);
  // plaintiff | defendant | hung — computed, then handed to the model
}

The Clerk's instruction also says: "Never invent numbers — every count comes from a tool result." In practice that holds because the tools return the counts and the model only has to quote them; we verify reports against the event log.

Why an agent at all, if code enforces the procedure? Because the valuable output is judgment about the numbers: which exhibit is load-bearing, what a holdout anchored on, what counsel should change. The Clerk reads twelve sealed reasonings and twelve deliberation statements per panel and writes the findings. The division of labor — the model narrates, the code decides — is the same doctrine we run in production elsewhere, and it is what makes the report auditable: every number it cites can be checked against a tool result in the run's event stream.

3. One run, end to end

Counsel / UI Run API Model Armor Clerk (ADK) Juror fleet Firestore POST /api/runs {spec, mode} run doc (queued) · 202 {runId} GET /events (SSE tail) sanitizeUserPrompt(packet) clean / BLOCKED → event "screened" runClerk(state) — runEphemeral provision_panel × (1 + variants) seal_verdicts: packet → 12 jurors (parallel) JSON verdicts → tally, quorum run_deliberation_round: packet + own verdict + anonymized digest POSITION lines → changes, holdouts, convergence, outcome events: provisioned · sealed · round · verdict (+ panels, provenance) diff_panels → findings report (final response) run doc: done · summary · metering · report SSE: report · done
Panels run concurrently (each variant is an independent set of tenants). Three panels of twelve seal and deliberate in ~35–60 s on the procedure path; the Clerk path adds its own reasoning turns (~2–3.5 min).

The juror protocol

PhaseWhat the juror receivesWhat it returnsWhat code does with it
SealThe identical packet (summary, claims, exhibits, witnesses, instructions) — nothing else{verdict, confidence, key_factors[], doubts[]} (fence-tolerant parser)Tally, mean confidence, quorum (≥⅔ answered); absent jurors recorded, never guessed
DeliberatePacket + its own sealed verdict + an anonymized digest of the others ("A fellow juror (defendant, confidence 70): …")3–5 sentences in persona + POSITION: side (confidence N)Parse position; mark changed; holdouts = confident minority; converged = unanimous; after the last round apply verdictRule → plaintiff / defendant / hung
VaryA variant panel gets the packet with an exhibit or a witness removed, or the summary/instructions replacedSame as aboveDiff sealed and final splits vs baseline; Δ plaintiff votes

Jurors are stateless across calls (A2A message/send carries no session); the decisions are not — they persist in Firestore and are replayed into each call by the orchestrator. All cross-juror information flows through the orchestrator's digest; a juror never sees another juror's name, persona, or identity.

4. Isolation, identity, and why it matters here

Single-context "jury" (the failure mode) one prompt plays 12 jurorsshared context ⇒ shared mind"deliberation" is narrationverdict = one opinion × 12 paraphrasesno commitment before cross-talk Virtual Jury Juror 1tenant rowown personaRLS stateHMAC-bound idsealed first Juror 2tenant rowown personaRLS stateHMAC-bound idsealed first × 12 per panel× panels in parallel Orchestratorthe only cross-juror channelanonymized digests onlyseeded personas: same seed ⇒same twelve people (reproducible)provenance: sha256(in), sha256(out)
Isolation is the product, not an implementation detail: the sealed distribution only means something if the twelve minds were actually separate when they committed.

Two things make the isolation real rather than asserted. Tenancy: each juror is a distinct workspace on the pooled service; PostgreSQL row-level security scopes every query to the tenant resolved from the request, and the A2A signature binds the tenant id into the signed string, so the orchestrator can only address the juror it signed for. Protocol: verdicts are sealed before any digest exists, and the digest that follows is anonymized — identities and personas never cross the boundary. The persona generator is a seeded PRNG (mulberry32), so a panel is reproducible from its seed; combined with stateless jurors, a whole run is a deterministic function of its inputs plus model sampling.

5. Google services, concretely

ServiceUsed forDetail
Gemini 3.7 Flash (Vertex AI)Every juror; paste-a-case intakeJurors: each pooled workspace's registry row names the model; the fleet calls Vertex. Intake: structured output with a responseSchema, temperature 0.2.
Gemini 3.1 Pro (Vertex AI)The Clerk of CourtADK LlmAgent@google/genai with GOOGLE_GENAI_USE_VERTEXAI=true, location global. Two models by role: pro-tier reasoning for the single agent that runs the procedure and writes the findings report; flash for the 12-way parallel juror fan-out.
Google ADK (TypeScript)The Clerk of CourtLlmAgent, FunctionTool, InMemoryRunner.runEphemeral, isFinalResponse. Tools are zod-typed; ADK bundles its own zod, so options are cast at the constructor boundary.
Model ArmorGateway screeningTemplate vj-case-intake (us-central1): prompt-injection & jailbreak (medium+), malicious URIs, RAI (dangerous, harassment), and Sensitive Data Protection (PII such as SSNs and card numbers — verified blocked live). Called via REST with ADC on every run packet and every intake text. A blocked packet fails the run closed; an unavailable API degrades to "not screened" and says so in the event stream — never silently.
GKEJuror fleetpooled-juror deployment in the roundtable namespace, rendered from the platform's pooled-service template with DOMAIN_TYPE=juror; plugin-free core image; workload identity rt-pooled-svc@ with Vertex + BigQuery roles.
Cloud RunAPI + UIOne service; --no-cpu-throttling so a run keeps executing after the 202; secrets injected from Secret Manager; min 1 / max 3 instances.
FirestoreRuns, events, panelsvirtual_jury_runs/{run}/events with transactionally sequenced seq (gap-free SSE replay across instances) and panels/{name} (jurors, sealed verdicts, rounds) — also the basis for idempotent re-provisioning.
BigQueryFleet telemetryView roundtable_telemetry.virtual_jury_juror_calls over the fleet's request traces; /api/metrics (calls, p50/p95, errors, hourly) feeds the UI's telemetry card.
Secret Manager · Cloud Build · Artifact Registry · Cloud MonitoringKeys, images, dashboardVIRTUAL_JURY_API_KEY, VIRTUAL_JURY_BRIDGE_HMAC; images tagged by git SHA; dashboard over Cloud Run request rate/latency and juror-pod CPU/memory.

6. Failure handling and limits

What it deliberately does not do. No damages or award modeling. No real case data (every case that ships with the product is synthetic). No verdict-accuracy claim: LLM juror populations converge to the majority fast and weigh objective exhibits decisively — we publish those dynamics in the examples rather than hide them, and calibration against published human mock-jury studies is pre-registered as future work. The product claim is argument stress-testing: which evidence is load-bearing, what holdouts anchor on, how a panel moves when an exhibit is gone.

7. Verify it yourself

  1. Open the app, choose an example from Examples ▾, keep Clerk of Court, empanel. Watch the procedure log: screenedprovisionedsealedroundverdictreportteardown.
  2. Compare any number in the report to the event it came from — the splits in the log are the tool results the Clerk was given.
  3. Click reasoning chain in Cloud Trace ↗ on a finished run: the jury.run trace shows every Clerk tool call with its result and every juror call underneath it. The telemetry card's counts come from BigQuery; the same juror calls appear there as fleet-side spans.
  4. Read the code: clerkAgent.ts (ADK), tools.ts (enforcement), deliberation.ts (protocol), roundtable.ts (fleet + HMAC). Tests: npm test — 49, including the procedure enforced with no pods.

Disclosure: Virtual Jury was created during the hackathon submission period on top of Roundtable, our pre-existing open-source agent runtime (Apache-2.0), used as the underlying platform with no core code changes. Everything described above the fleet line is new work.