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.
| Fortified Enterprise Fleet component | Implementation | Where |
|---|---|---|
| Agent Registry | Each 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 Bank | Runtime: 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 Armor | Tenant-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 |
| Observability | OpenTelemetry → 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 |
The orchestrator is a single ADK LlmAgent — the 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.
seal_verdicts, so no instruction-following failure can leak one juror's view to another before commitments are recorded.// 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.
// 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.
| Phase | What the juror receives | What it returns | What code does with it |
|---|---|---|---|
| Seal | The 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 |
| Deliberate | Packet + 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 |
| Vary | A variant panel gets the packet with an exhibit or a witness removed, or the summary/instructions replaced | Same as above | Diff 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.
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.
| Service | Used for | Detail |
|---|---|---|
| Gemini 3.7 Flash (Vertex AI) | Every juror; paste-a-case intake | Jurors: 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 Court | ADK 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 Court | LlmAgent, FunctionTool, InMemoryRunner.runEphemeral, isFinalResponse. Tools are zod-typed; ADK bundles its own zod, so options are cast at the constructor boundary. |
| Model Armor | Gateway screening | Template 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. |
| GKE | Juror fleet | pooled-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 Run | API + UI | One service; --no-cpu-throttling so a run keeps executing after the 202; secrets injected from Secret Manager; min 1 / max 3 instances. |
| Firestore | Runs, events, panels | virtual_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. |
| BigQuery | Fleet telemetry | View 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 Monitoring | Keys, images, dashboard | VIRTUAL_JURY_API_KEY, VIRTUAL_JURY_BRIDGE_HMAC; images tagged by git SHA; dashboard over Cloud Run request rate/latency and juror-pod CPU/memory. |
QuorumError ends the run with the reason in the stream.done or error, every juror workspace it provisioned is deleted (registry row, tenant identity, and row-level data — a teardown event in the stream records the count). A sweeper on boot and hourly deletes any stray juror rows older than 2 hours (runs finish in under 2 minutes), so a crashed run can't leak tenants. Reopening an old run still replays fully: it reads the run's own persisted events, not the fleet.screened → provisioned → sealed → round → verdict → report → teardown.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.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.