{
  "markdown": "# Privacy-Preserving Multi-Agent Gateway\n\n_日本語版: [README.ja.md](README.ja.md)_\n\n**All Things Agentic Hackathon** — category: _Fortified Enterprise Fleet_ (also targeting\n_Best Architectural Design_).\n\nEnterprises want frontier-model reasoning but cannot send raw PII or secrets outside their\ntrust boundary. This fleet lets **Gemini** reason over _tokenized_ text while an open model\n(**Gemma**) that never leaves the boundary owns the mapping back to real values. Placeholders\nare a **pseudonym**, not anonymization — see [Pseudonymization, not anonymization](#pseudonymization-not-anonymization)\nbelow.\n\n```\nUser ──HTTP──▶ Gateway (Gemma)\n                 │ 1. detect + tokenize ──▶ Firestore Token Vault (request_id → {token: value})\n                 │ 2. masked prompt          (egress guard re-scans before sending)\n                 ▼ A2A\n               Core (Gemini 3.5)  — reasoning / planning / codegen over placeholders only\n                 │ masked answer\n                 ▼ HTTP\n               Synthesis (Gemma)\n                 │ 3. leak check  4. consistency/resolvability  5. rehydrate once\n                 │ 6. positional verification  7. release\n                 ▼\n               User  (OKF answer document + audit trail)\n```\n\n![Architecture: the trust boundary, the three agents, the six consumption surfaces and the cost kill switch](docs/diagram/architecture.drawio.png)\n\nThe PNG embeds its own draw.io source — open it in draw.io to edit\n([`docs/diagram/architecture.drawio`](docs/diagram/architecture.drawio) is the same diagram).\n\nGateway → Core is the only A2A hop. Gateway → Synthesis is plain authenticated HTTP,\ndeliberately: the OKF document is an audit artifact and must be retrieved without an LLM\nrephrasing it. See [A2A, precisely](#a2a-precisely) below.\n\nFull design: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. Deployment:\n**[docs/DEPLOY.md](docs/DEPLOY.md)**. Logs, traces and error codes:\n**[docs/OBSERVABILITY.md](docs/OBSERVABILITY.md)**.\n\n## Why this is more than \"regex before an API call\"\n\n- **The boundary is deployable, not conventional.** Core runs as a separate service with its\n  own IAM identity and _no_ Firestore role. It cannot read the vault even if its code tried.\n- **The boundary is also in the dependency graph.** `packages/common` publishes subpath\n  exports, and Core imports only `@privacy-gateway/common/{logging,config,schema,telemetry}` —\n  none of which reach the vault. A future edit that tries to read the vault from Core has to\n  add an import that does not exist.\n- **Two independent gates.** The Gateway re-scans every outbound prompt with a deterministic\n  detector and refuses to send if any raw identifier survived masking. The Synthesis agent\n  independently gates the response before rehydration.\n- **The verdict is deterministic.** The leak check is an OKF _Attested Computation_ whose\n  attester re-derives its own findings from the response text — a runner that under-reports\n  fails rather than passes. The Gemma judge is advisory and **asymmetric**: `leak: true` or\n  no usable verdict blocks the release, `leak: false` adds no trust at all. A probabilistic\n  model may veto; it may never vouch.\n- **Trust is a portable artifact.** Every answer is an OKF v0.2 document you can `cat`, diff\n  and hand to any OKF consumer.\n- **Everything fails closed.** See [Refusals](#refusals) below for the full list — every\n  refusal returns no rehydrated answer and persists only masked artifacts.\n\nSee also the review at [docs/reviews/2026-08-24-response.md](docs/reviews/2026-08-24-response.md)\n(日本語: [docs/reviews/2026-08-24-codex-design-review.ja.md](docs/reviews/2026-08-24-codex-design-review.ja.md))\nfor the design decisions and known limitations behind these choices.\n\n## Required-tech checklist\n\n|     | Requirement                  | Where                                                                                                                                |\n| --- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| ✓   | **Gemini 3.5 via Vertex AI** | Core Agent (`agents/core`), `gemini-3.5-flash` on the **global** endpoint. Model id from `GEMINI_MODEL`.                             |\n| ✓   | **Google ADK**               | All three agents, ADK TypeScript (`@google/adk` 2.0.0).                                                                              |\n| ✓   | **A2A**                      | Gateway → Core, via Agent Card + `message/send`. Gateway → Synthesis is plain HTTP by design — see [A2A, precisely](#a2a-precisely). |\n| ✓   | **Cloud Run**                | One service per agent, plus Gemma serving on Cloud Run GPU (NVIDIA RTX PRO 6000) and the `kill-switch` service.                      |\n| ✓   | **Firestore**                | Token Vault and the OKF answer store — both with a TTL policy on `expires_at`.                                                       |\n| ✓   | **Gemma (bonus)**            | Gateway span extraction and the Synthesis judge, self-hosted via Ollama.                                                             |\n\nGateway and Synthesis reach Gemma through **`OllamaLlm`**, a custom ADK `BaseLlm` adapter in\n`packages/common` registered in `LLMRegistry` for model names matching `ollama/*`. It speaks\nOllama's OpenAI-compatible `/v1/chat/completions`, so the same code path serves local Ollama\nin development and Cloud Run GPU in production.\n\n## Deployed endpoints\n\nThe fleet is live in project `all-thinkgs` (`us-central1`). Only the Gateway is public;\nevery other service is private (IAM invoker + ID token) or internal-ingress only.\n\n| Service           | URL                                               | Access                            |\n| ----------------- | ------------------------------------------------- | --------------------------------- |\n| `gateway-agent`   | <https://privacy-gateway.kexi.dev>                | **public** — the demo entry point |\n| `core-agent`      | `https://core-agent-turszib42q-uc.a.run.app`      | private (A2A, ID token)           |\n| `synthesis-agent` | `https://synthesis-agent-turszib42q-uc.a.run.app` | private (HTTP, ID token)          |\n| `gemma-serving`   | `https://gemma-serving-turszib42q-uc.a.run.app`   | internal ingress only             |\n| `kill-switch`     | `https://kill-switch-turszib42q-uc.a.run.app`     | private (Pub/Sub push + OIDC)     |\n\n> **Judging-window access.** The hosted Gateway is gated behind HTTP Basic auth\n> (credentials in the Devpost submission's testing instructions, never in this\n> repository). Add `-u user:pass` to the curl examples below. The OpenAI SDK's\n> `api_key` field cannot carry it (it sends `Bearer`; the gate accepts only\n> `Basic`) — per-client instructions, including Codex and the SDK header\n> workaround, are in [`skills/pgw-client/CLIENT.md`](skills/pgw-client/CLIENT.md)\n> §0. The MCP server, the Ollama shim and `pgw.py` have no credential channel\n> yet: run those against a local or ungated deployment.\n\n```bash\ncurl -sS https://privacy-gateway.kexi.dev/v1/ask \\\n  -H 'content-type: application/json' \\\n  -d '{\"text\":\"Customer Taro Yamada (taro@example.co.jp) reports a failed charge.\"}'\n```\n\nAdd `mask_terms` for anything the detectors could not know to protect — an unreleased\nproduct name, an internal codename:\n\n```bash\ncurl -sS https://privacy-gateway.kexi.dev/v1/ask \\\n  -H 'content-type: application/json' \\\n  -d '{\"text\":\"Summarize the status of Titan Project for the board.\",\n       \"mask_terms\":[\"Titan Project\"]}'\n```\n\n`just urls` regenerates this list from Terraform, and `just health` probes every service\nwith an ID token.\n\n## Six ways to consume it\n\nOne pipeline, six entry points — whichever you use, the same fail-closed gates run and the\nsame masked evidence is stored.\n\n| Surface               | Entry point                       | Best for                                                    |\n| --------------------- | --------------------------------- | ----------------------------------------------------------- |\n| **Web UI**            | `/` on the Gateway (built SPA)    | the demo: masked prompt and final answer side by side       |\n| **REST**              | `POST /v1/ask`                    | the full result — trust dimensions, attestation, stats      |\n| **OpenAI-compatible** | `POST /v1/chat/completions`       | dropping the fleet into an existing OpenAI client           |\n| **MCP**               | `clients/mcp` (stdio)             | giving an agent ask / evidence / verify tools               |\n| **Model picker**      | `clients/ollama-shim` (localhost) | selecting the fleet as a _model_ in Claude Desktop          |\n| **Python CLI**        | `clients/python/pgw.py`           | a dependency-light example, and full bundle-digest checking |\n\nEach is documented below: [API](#api), [use as a model](#use-privacy-gateway-as-a-model-in-any-openai-compatible-client),\n[MCP](#the-mcp-server), [Python client](#the-python-client-language-agnostic-consumption).\n\nThe model-picker shim serves the **Anthropic Messages API** (`GET /v1/models`,\n`POST /v1/messages`), because that — not the Ollama protocol — is what Claude Desktop's\nthird-party gateway actually speaks; it also serves the native Ollama API for `ollama`\nclients. See [`clients/ollama-shim/README.md`](clients/ollama-shim/README.md) for the\nresearch, the sources, and the setup steps.\n\n## Repository layout\n\nOne pnpm workspace; every agent is ADK TypeScript.\n\n```\npackages/common/   # tokenizer, vault, OKF, guard, logging, telemetry, zod schemas,\n                   # the A2A client, OllamaLlm, and the OKF bundle's attester\nagents/gateway/    # ADK agent + HTTP entry + serves web/dist\nagents/core/       # ADK agent (Gemini) + A2A server\nagents/synthesis/  # ADK agent + A2A server + HTTP routes\nservices/kill-switch/  # cost kill switch: budget notification -> stop spending\nclients/mcp/       # MCP stdio server: pgw_ask / pgw_evidence / pgw_verify\nclients/python/    # pgw.py — single-file PEP 723 client, the language-agnostic example\nserving/gemma/     # Ollama Dockerfile for Cloud Run GPU\nweb/               # demo UI (masked vs final, side by side) + Playwright specs\nknowledge/         # OKF v0.2 bundle: policy, attested computation, executor skill\ninfra/terraform/   # Terraform: Cloud Run, IAM, Firestore TTL, Artifact Registry\n```\n\nThe workspace packages are `web`, `packages/common`, `agents/core`, `agents/gateway`,\n`agents/synthesis`, `services/kill-switch` and `clients/mcp`. The kill switch sits under `services/` rather\nthan `agents/` because it is not a member of the reasoning fleet: it never sees a prompt, an\nanswer or a vault entry.\n\nRelative imports carry the **`.ts` extension** (`import { x } from './x.ts'`), enabled by\n`allowImportingTsExtensions` + `rewriteRelativeImportExtensions`; tsc rewrites them to `.js`\non build. Source therefore names the file that actually exists, and no import needs to be\nmentally translated between the editor and the build output.\n\n## zod at every boundary\n\nHTTP request and response bodies, A2A payloads, Gemma's JSON output, the environment config\nand the OKF frontmatter are all defined as zod schemas in `packages/common`. The env schema\nis validated at startup, so an invalid value stops the process with a `config.invalid` log\nline instead of surfacing halfway through a request. `web` derives its TypeScript types from\nthose same schemas via `z.infer` rather than hand-writing them, so a change to a response\nshape breaks the UI's type check instead of the demo.\n\n## Observability\n\nEvery service emits **structured JSON logs**, one object per line, in the shape Cloud Logging\ningests without a sidecar. Logging is a **typed allowlist, not recursive scrubbing**: only\nnamed fields (hashes, counts, enums, internal UUIDs) are emitted, everything else is dropped\nwith the dropped key names under `dropped_fields`, and exception messages never reach logs or\nspans. No raw PII ever reaches a log: string values pass through the tokenizer first, so a\nleaked value appears as `⟦EMAIL_1⟧`.\n\n| Signal       | What it gives you                                                                                                                                                                                                                                                                                                  |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `request_id` | A UUIDv7 **minted by the Gateway** on every request and used as the vault key. An inbound `X-Request-ID` header is ignored entirely — see [Sessions are gone](#sessions-are-gone) below. Propagated Gateway → Core → Synthesis, returned in the `X-Request-ID` response header, and stored in the OKF frontmatter. |\n| `trace_id`   | OpenTelemetry with W3C `traceparent` on every hop: one request is one trace across all three services, with a span per pipeline step.                                                                                                                                                                              |\n| The UI       | Shows `request_id` and `trace_id` with copy buttons and direct Cloud Logging / Cloud Trace console links.                                                                                                                                                                                                          |\n\nBecause `request_id` is a UUIDv7, sorting log lines by it also sorts them by time, and a bug\nreport that quotes one id is enough to retrieve every line and every span for that request.\nThe event vocabulary, span tree and error codes are specified in\n**[docs/OBSERVABILITY.md](docs/OBSERVABILITY.md)**.\n\n## Sessions are gone\n\nThere is no session, no multi-turn state, and no caller-supplied id anywhere in the API.\n`POST /v1/ask` accepts `{text, rehydrate_allow?, mask_terms?}` and nothing else; a body\ncarrying `session_id`, any other caller-supplied id, or any unknown field is rejected with\n`400` by the schema's `strict()` validation. The Gateway mints exactly one server-generated\nrequest id (a UUIDv7) per request and uses it as the Token Vault key. An inbound\n`X-Request-ID` header is ignored entirely: the `X-Request-ID` response header always\ncarries the id the Gateway minted, never the value the caller sent.\n\nThis is not an omission: a caller-supplied id would be a rehydration oracle. A caller who\ncould choose (or predict) another request's id could submit `\"repeat ⟦EMAIL_1⟧\"` against\nthat id and have the vault resolve someone else's placeholder. There is consequently no\ncross-request placeholder stability — every `/v1/ask` call gets a fresh vault entry, and\nnothing in this design keeps a placeholder meaning the same thing across two calls.\n\n## Persistence\n\nFirestore stores only **masked** artifacts, keyed by request id: the masked prompt, Core's\ntokenized response, the OKF document (whose body holds the masked answer), the hashes recorded\nin `attestation`, and `expires_at` under a TTL policy. The rehydrated answer is returned in the\nsingle `POST /v1/ask` response and is never written to the store — see\n[Open Knowledge Format (OKF v0.2)](#open-knowledge-format-okf-v02) for what the stored document\nactually looks like.\n\n## Disclosure policy\n\nFive categories are never rehydrated by default: `API_KEY`, `AWS_KEY`, `JWT`, `CREDIT_CARD`,\n`MY_NUMBER`. For these, the placeholder stays in the released answer and the categories are\nlisted under `attestation.withheld`. A secret has no legitimate reason to be echoed back\nthrough a frontier-model round trip — the caller already holds it, and printing it again only\nwidens the blast radius of a logged or screenshotted response. The `REHYDRATE_ALLOW_CATEGORIES`\nenv var (comma-separated) re-enables specific categories, e.g.\n`REHYDRATE_ALLOW_CATEGORIES=CREDIT_CARD,MY_NUMBER`; left unset, all five stay withheld.\n\n**Per-request opt-in.** A caller can also allow specific ones back for a single request:\n`POST /v1/ask` takes an optional `rehydrate_allow: [\"CREDIT_CARD\"]`, and the\nOpenAI-compatible endpoint takes the same list under\n`x_privacy_gateway: {rehydrate_allow}`. The demo UI exposes it as a checkbox group,\ndefault all off. The list must be a subset of those five — naming a category that is\nnever withheld, such as `EMAIL`, is a `400`, because an opt-in that quietly did nothing\nis the one failure mode this must not have. It is unioned with\n`REHYDRATE_ALLOW_CATEGORIES`, and covers only values submitted **in that same request**:\none request, one vault key, no session for the permission to persist into. The record\nkeeps the two apart — `attestation.disclosure_requested` is what was asked for,\n`attestation.withheld` is what was still not given — and the stored OKF body stays masked\neither way.\n\n**Rehydration is verified positionally.** After the single rehydration, Synthesis rebuilds\nthe answer it expected to release: it walks Core's tokenized answer once with a\nplaceholder regex transcribed rather than imported — a deliberately independent second\ncopy, since a check sharing the tokenizer's pattern cannot see a tokenizer bug —\nsubstituting each placeholder's vault value, copying withheld placeholders through\nverbatim, and copying everything between them unchanged. The rebuilt string must equal\nthe released string **exactly**. That catches what set-and-substring checks could not: two\nvalues of the same category filled in the wrong order pass \"every value is present\nsomewhere\" while telling the reader Bob's address was Alice's. Leftover/missing\nplaceholders and per-token substitution checks still run first, but only as diagnostic\npreambles that name what broke. A rebuild failure is `rebuild_mismatch` and carries no\nvalues, no excerpt and no categories — an empty token list — because the strings being\ncompared are the answer itself. Any violation is `500 rehydration_incomplete` — the only\n5xx refusal, because it is our bug rather than the caller's — and the body is withheld\nlike any other refusal. Releases carry\n`attestation.rehydration: {substituted, withheld_remaining, verdict}`.\n\n## User-defined secret terms\n\nDetection covers _shapes_ — an email looks like an email, a card number carries a\nchecksum, a personal name is something Gemma recognises. An unreleased product name or an\ninternal codename has no shape at all: its confidentiality is a fact about the enterprise,\nnot about the string, so no regex and no model can know to protect it.\n\nSo the requester names it. `POST /v1/ask` takes an optional\n`mask_terms: [\"Titan Project\"]` (1–20 terms, 2–120 characters each, no `⟦`/`⟧`), the\nOpenAI-compatible endpoint takes the same list under `x_privacy_gateway: {mask_terms}`,\nMCP `pgw_ask` takes a `mask_terms` parameter, and the demo UI has a comma-separated field\nwith a chip preview. Each term becomes a `⟦CUSTOM_n⟧` placeholder in an exact-match pass\nthat runs **before** every detector; longer terms substitute first, so naming both `Titan`\nand `Titan Project` yields one placeholder for the longer phrase rather than splitting it.\n\n**Matching is case-sensitive.** A codename's case is part of its identity — `Titan` the\nproduct is not `titan` inside \"titanium alloy\" — so folding case would mask ordinary prose\nnobody asked to hide and mangle the prompt Core reasons about. Name both spellings to mask\nboth.\n\n`CUSTOM` is **not** withheld: unlike the five high-risk categories above, a term is\nrestored into the answer by default. The requester typed it into this very request, so\nwithholding protects nothing they do not already hold, and a reply about `⟦CUSTOM_1⟧` is\nunreadable to the person who asked about their own codename. What protects them is that\nthe term never crossed the boundary.\n\n**This is the strongest check on the boundary.** Both the egress guard (over the outbound\nmasked prompt) and the deterministic attester (over Core's tokenized answer) scan for each\nterm literally, and either one finding it refuses the request — `422\noutbound_guard_refused` or `422 leak_check_failed`, category `CUSTOM`. Every other guard\ncheck re-runs the same patterns that decided the masking, so it can only catch a tokenizer\nbug over shapes it already knows; a literal comparison catches a failed substitution\noutright. It is the one category where the fleet can _prove_ the masking worked.\n\nThe term list is never persisted to evidence or logs; matched values are stored only in the\nTTL'd Token Vault for rehydration, like every masked value. The list itself travels Gateway\n→ Synthesis (both inside the boundary) and is written nowhere; a term that matched becomes\na vault mapping entry keyed by request id, expiring with it, because rehydration has no\nother way to restore it. The audit record keeps only `attestation.custom_terms: {count: N}`\n— not a digest, because a codename comes from a small guessable space and a hash of one is\na confirmation oracle rather than a redaction. Logs carry `term_count` and\n`surviving_term_count`; the logging allowlist has no field a term could travel in.\n\n## Text only, by design\n\nEvery surface is text-only. A non-text content part is refused by name, never dropped:\n`/v1/chat/completions` answers `400 multimodal_unsupported` listing the kinds it saw\n(`image_url`, `input_audio`, …), the Anthropic/Ollama shim refuses an `image` block the\nsame way, and MCP `pgw_ask` takes a `string` with no shape an attachment could arrive in.\nRedaction here is deterministic regex plus a text model, so PII inside an image or an\naudio clip — a face, a whiteboard, a screenshot of a card, a name read aloud — cannot be\nfound, masked or verified by any gate in this fleet. Accepting the part and dropping it\nwould send a prompt the caller did not write; forwarding it would put unmaskable data\nacross the boundary. In-boundary Gemma vision extraction is the planned way to support it.\n\n## Open Knowledge Format (OKF v0.2)\n\nEvery answer the fleet produces is agent-written content, so provenance and trust are\nfirst-class on each output. We adopt\n[OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format).\n\nThe repository bundle is `knowledge/`:\n\n- `policies/pii-masking.md` — what must be masked, authored and `verified` by `human:kei`.\n- `computations/leak-check.md` — `type: Attested Computation`, `runtime: typescript`, with\n  `executor.receipt: [request_id, masked_prompt_hash, response_hash, findings, response]`\n  (exported as `RECEIPT_FIELDS` — the same five fields `verify()` demands) and\n  `attester.resource: /references/attesters/leak_check.ts`.\n- `references/skills/run-leak-check.md` — the executor's run instructions.\n\nThe attester's source lives at `packages/common/src/attesters/leak_check.ts` (regex only, no\nLLM, no network) and is published as `@privacy-gateway/common/attesters/leak-check`. A\nbyte-identical copy lives at `knowledge/references/attesters/leak_check.ts` — the resource the\nbundle declares — held equal to the real module by a test on their SHA-256 digests, so the\nbundle's declared attester and the one Synthesis actually runs cannot drift apart silently.\n\nEach request produces a `type: Gateway Answer` concept. `generated.by` is\n`synthesis_agent/<version>`: Synthesis assembles the concept, so §7 attributes the document to\nit, while Core's tokenized prose appears as provenance instead — a `core-response` source\nauthored by `core_agent/<model>`. `sources[]` lists three entries: `masked-prompt`\n(`/requests/<id>/masked-prompt.md`), `core-response` (`/requests/<id>/core-response.md`), and\n`pii-policy` — the first two are actually served by the Gateway. `verified[].by` is\n`process:leak-check@<attester sha256 short>` once the attestation passes (⇒\n_machine-confirmed_) — **never an LLM**, and never a `human:` actor (see\n[Human approval, removed](#human-approval-removed) below). `stale_after` equals the vault\nexpiry, and `request_id` / `trace_id` carry correlation. A failed attestation yields\n`status: draft`, no `verified` entry, and the reason recorded under `# Attestation` —\nsurfaced, never dropped. A malformed `verified` entry derives `unverified`, and an invalid or\nabsent `stale_after` derives freshness `unknown` — never `fresh`.\n\nA new top-level `attestation:` frontmatter block carries everything a third party needs to\nreplay the verdict: `computation`, `computation_sha256`, `attester_sha256`,\n`masked_prompt_sha256`, `core_response_sha256`, `verdict`, `checked_at`, `request_id`,\n`trace_id`, and an optional `withheld` list of categories the disclosure policy kept masked.\nSee [Disclosure policy](#disclosure-policy) below.\n\nTrust tiers are **derived** from `verified`, never stored — server-side, again in the UI\n(`web/src/api.ts`), and once more in the Python client.\n\nAgent-facing guidance for writing OKF in this repo lives in `skills/okf/`.\n\n## Local spin-up\n\nPrerequisites: [pnpm](https://pnpm.io/) (via corepack), [just](https://just.systems/),\nNode.js 22, [Ollama](https://ollama.com/) for Gemma, and [uv](https://docs.astral.sh/uv/) if\nyou want to run the Python client.\n\n```bash\ncp .env.example .env       # then edit\njust setup                 # pnpm install\njust pull-gemma            # ollama pull gemma4:12b\n```\n\n`just dev` starts all four processes — Gateway (8081), Core (8082), Synthesis (8083) and the\nVite dev server (5173) — with an in-memory vault:\n\n```bash\njust dev            # gateway + core + synthesis + web\n```\n\nOpen <http://localhost:5173>. Vite proxies `/v1` and `/healthz` to the Gateway, so the UI\nuses the same relative paths in dev and in production.\n\nEach service can also be run on its own:\n\n```bash\njust dev-gateway    # port 8081\njust dev-core       # port 8082\njust dev-synthesis  # port 8083\n```\n\nTo serve the built UI from the Gateway itself (as in production):\n\n```bash\njust web-build      # produces web/dist\njust dev-gateway    # http://localhost:8081\n```\n\n`just web-build` runs `pnpm -r build`, so it also compiles `clients/mcp` to\n`clients/mcp/dist/` — the entry point the MCP client configs point at.\n\n### Checks\n\n```bash\njust check          # the full CI-equivalent suite\njust test           # vitest across the workspace\njust test-coverage  # the same, with coverage thresholds enforced\njust typecheck      # tsc --noEmit, per package\njust lint-ts        # oxlint\njust fmt-ts         # oxfmt\n```\n\nThe root `vitest.config.ts` uses `test.projects`, so\n`just test` runs everything from the repository root while each package keeps its own `test`\nscript for `pnpm --filter X test`. `just test-coverage` uses `@vitest/coverage-v8` and\nenforces per-package floors: `packages/common` at 90% lines (it holds the masking, vault and\nOKF logic the guarantees rest on), the agents at 70% (thinner orchestration over it).\n\nThe suite runs entirely offline: LLMs are mocked and the Core agent is replaced by a mock A2A\nserver (Agent Card + `message/send`), so the boundary guarantees are exercised without a\nnetwork.\n\n### Browser tests\n\n```bash\njust web-e2e        # Playwright, chromium only\njust setup-browsers # once, outside Nix\n```\n\nThe Playwright specs in `web/e2e/` drive the real Gateway and Synthesis with only Core (over\nA2A) and Gemma (over the OpenAI-compatible API) mocked, so what the browser exercises is the\nproduction request path rather than a stubbed API. Chromium only: these assert application\nbehaviour, not rendering differences, and a second engine would double the runtime for no\nextra signal. Under Nix the browser comes from `PLAYWRIGHT_BROWSERS_PATH`; outside Nix run\n`just setup-browsers` (`pnpm -C web exec playwright install chromium`) once.\n\n### Reproducible testing\n\nEvery claim in this README is meant to be re-checkable by someone who did not write it.\nThis section is the shortest path from a clean checkout to having verified them yourself.\n\n**1. Everything, offline, from a clean checkout.** No cloud account and no API key:\n\n```bash\ndirenv allow                # or: nix develop\njust setup                  # pnpm install\njust check                  # fmt, recipe docs, lint, tf-validate, typecheck, pinact, gitleaks, tests\njust web-e2e                # browser specs (needs `just setup-browsers` outside Nix)\n```\n\n`just check` runs the CI-equivalent checks locally in one command — lint, formatting,\nTerraform validation, typecheck, pin verification, secret scanning and the unit tests. The\nbrowser E2E suite is a separate gate, `just web-e2e`. CI covers the same ground but splits\nit across parallel jobs rather than invoking `just check`, and it runs both gates on pushes\nto `main` and on every pull request. The canonical evidence of what passes, and of how many\ntests there are on any given commit, is the workflow run itself:\n<https://github.com/kexi/privacy-gateway/actions/workflows/ci.yml>. Counts move with every\ncommit, so this README does not quote them — trust the numbers your own run prints.\n\nTwo caveats worth knowing before you conclude something is broken:\n\n- `agents/synthesis/test/dist_attestation.test.ts` builds the package and boots the built\n  output to prove the attestation digests are real in a production image rather than the\n  `unavailable` placeholder a packaging bug once produced. It is the slowest test and the\n  only timing-sensitive one; under heavy parallel load it can occasionally time out. Re-run\n  before investigating.\n- The suite pins **behaviour, not wording**. Tests assert refusal kinds and status codes, so\n  they survive prose changes and fail on semantic ones.\n\n**2. The privacy guarantees specifically.** The interesting tests are the ones that would\nfail if masking, the vault boundary or a fail-closed gate regressed:\n\n```bash\npnpm --filter @privacy-gateway/common test      # masking, vault, OKF, logging allowlist\npnpm --filter @privacy-gateway/synthesis test   # the five release gates, the judge asymmetry\npnpm --filter @privacy-gateway/core test        # the boundary: Core never sees a real value\n```\n\n`agents/synthesis/test/pipeline.test.ts` is the file to read first. Each gate has a test\nthat names what it guarantees, including that a judge flag can never be retried into a\nrelease and that no refusal path rehydrates.\n\n**3. Verify one answer's attestation without trusting the fleet.** The gateway's own claim\nabout a request is checkable against the artifacts it serves, using code that is not the\nfleet's:\n\n```bash\njust verify-answer <request_id>                                       # against localhost:8081\njust verify-answer <request_id> https://privacy-gateway.kexi.dev      # against production\n```\n\nThe recipe is a one-line wrapper over `uv run clients/python/pgw.py verify <id> --base <url>`,\nso the client can be run directly if you would rather see the invocation.\n\nThe Python client re-hashes the masked prompt and the Core response the gateway serves,\ncompares every digest the OKF document records, and re-derives the verdict with a\ntranscribed copy of the scanner. It reports what it could **not** check rather than\ncounting it as passed — `attester_sha256` and `computation_sha256` name files in this\nrepository, so only a checkout can compare those two.\n\n**4. Against the live deployment.** These need `gcloud` auth to the project:\n\n```bash\njust smoke                  # POST a fixed PII sample; assert 200, placeholders present, raw PII absent\njust image-test             # boot the real Synthesis image; assert real attestation digests\njust verify-auth            # from a laptop: prove the private services are NOT reachable\njust verify-auth-internal   # from inside the VPC: prove IAM accepts an authorized caller\n```\n\n`verify-auth` and `verify-auth-internal` are a pair on purpose. A test that only shows a\n`403` from outside cannot distinguish \"correctly locked down\" from \"broken\", so the second\none proves the same door opens for the right caller.\n\n**5. Reproduce the evidence in `docs/proof/`.** Every file there records the command that\nproduced it in a comment header, so it can be re-run rather than taken on trust.\n[`docs/proof/README.md`](docs/proof/README.md) indexes them and, at the top, lists the\ndefects that were found this way and have since been fixed — including a kill switch that\nreported success while capping nothing. The failures are kept deliberately: a proof\ndirectory that quietly deletes its own mistakes is worth less than one that shows them\nbeing closed.\n\n### API\n\n| Method | Path                                 | Purpose                                                                                                                                                                                                           |\n| ------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `POST` | `/v1/ask`                            | `{text}` plus optional `rehydrate_allow` / `mask_terms` → masked prompt, ephemeral rehydrated answer, OKF document, four trust dimensions, attestation, consistency, stats                                        |\n| `GET`  | `/v1/requests/{id}`                  | the stored **masked** OKF evidence document (markdown)                                                                                                                                                            |\n| `GET`  | `/v1/requests/{id}/masked-prompt.md` | the masked prompt sent to Core                                                                                                                                                                                    |\n| `GET`  | `/v1/requests/{id}/core-response.md` | Core's still-tokenized response                                                                                                                                                                                   |\n| `POST` | `/v1/chat/completions`               | OpenAI-compatible façade over the same pipeline (see below)                                                                                                                                                       |\n| `GET`  | `/v1/models`                         | OpenAI-compatible model list; one id, `privacy-gateway`                                                                                                                                                           |\n| `GET`  | `/v1/status`                         | is Gemma `warm`/`warming`/`cold`/`unknown`, and the cold-start estimate. Cheap, cached ~5s, and never wakes the GPU                                                                                               |\n| `POST` | `/v1/warmup`                         | starts the GPU. **Billed while the instance lives** (~15 idle minutes), so it is rate-limited like `/v1/ask`                                                                                                      |\n| `GET`  | `/v1/audit`                          | read-only list of stored evidence metadata, newest first (max 50). Requires an `X-Admin-Token` header (a `?key=` query is refused; the shareable link is `/audit#key=`); 404 when `ADMIN_TOKEN` is unset or wrong |\n| `GET`  | `/healthz`                           | liveness                                                                                                                                                                                                          |\n\n`POST /v1/ask` also answers `Accept: text/event-stream` with a progress stream: an\n`event: progress` frame per pipeline stage (`masking`, `egress_guard`, `core_reasoning`,\n`leak_check`, `rehydrate`) carrying only the stage name, a `start`/`end` marker and\n`elapsed_ms`, then a terminal `event: result` with the same `AskResponse` body the JSON\npath returns — or `event: refused` carrying the error body and the status it would have\nhad — followed by `data: [DONE]`. No progress frame ever carries prompt text, an answer\nfragment or a placeholder. The status code is `200` from the first frame onwards, because\nthe headers are flushed long before the pipeline knows whether it will refuse; a streaming\nclient reads the refusal frame's `status` field instead. The OpenAI-compatible\n`stream: true` is unchanged (one content chunk, then `[DONE]`).\n\n`/v1/status` reports `warm` when a Gemma call was recorded in the last 10 minutes,\n`warming` when a `/v1/warmup` was dispatched in the last 3 minutes and no Gemma call has\nlanded since, `cold` when neither holds, and `unknown` when the record could not be read.\nIt is derived from timestamps written after each successful Gemma call and each dispatched\nwake, never by probing Gemma, because a probe would wake the instance it is reporting on.\n`warm` always wins over `warming`: activity proves residency, while a wake only predicts\nit. A wake that produces no Gemma call inside its window expires back to `cold`, so the\nbutton can be pressed again.\n\nThere is no session-based API any more: `GET /v1/sessions/{id}/answer`,\n`POST /v1/sessions/{id}/approve` and `GET /v1/sessions/{id}/tier` are all removed. The evidence\ndocument and its two source artifacts are all that persists server-side; the rehydrated answer\nis returned once, in the `/v1/ask` response body, and never stored (see\n[Persistence](#persistence) below).\n\n#### Refusals\n\nEvery failure mode below fails closed: no rehydrated answer is returned, and only masked\nartifacts are persisted.\n\n| Condition                                              | Status                             |\n| ------------------------------------------------------ | ---------------------------------- |\n| Reserved `⟦…⟧` syntax in the input                     | `400`                              |\n| A `session_id` field in the request body               | `400`                              |\n| Span extraction unusable or unavailable                | `502` (request never reaches Core) |\n| Egress guard finds raw PII in the outbound prompt      | `422`                              |\n| Vault mapping missing                                  | `409`                              |\n| Vault mapping expired                                  | `410`                              |\n| Vault generation mismatch                              | `409`                              |\n| Core invented a placeholder absent from the prompt     | `409`                              |\n| Leak check failed                                      | `422`                              |\n| Gemma judge flags a leak, or returns no usable verdict | `422`                              |\n| Unresolved placeholder in the response                 | `409`                              |\n| Over the rate limit                                    | `429`                              |\n| Request body too large                                 | `413`                              |\n| Gateway deadline exceeded                              | `504`                              |\n\n### Use `privacy-gateway` as a model in any OpenAI-compatible client\n\nPoint an existing OpenAI-compatible client at the gateway as its `base_url` and select\n`privacy-gateway` as the model. No code change, and every gate below still applies.\n\n```bash\ncurl -sS http://localhost:8081/v1/chat/completions \\\n  -H 'content-type: application/json' \\\n  -d '{\n        \"model\": \"privacy-gateway\",\n        \"messages\": [\n          {\"role\": \"system\", \"content\": \"You are terse.\"},\n          {\"role\": \"user\", \"content\": \"Draft a reply to taro@example.co.jp about the failed charge.\"}\n        ]\n      }'\n```\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(base_url=\"http://localhost:8081/v1\", api_key=\"unused\")\ncompletion = client.chat.completions.create(\n    model=\"privacy-gateway\",\n    messages=[{\"role\": \"user\", \"content\": \"Draft a reply about the failed charge.\"}],\n)\nprint(completion.choices[0].message.content)\n```\n\nCodex CLI selects it the same way, over the **Responses API** (`POST /v1/responses`).\nCodex ≥ 0.149 dropped `chat/completions` for custom providers and refuses to start with\n`wire_api = \"chat\"`; the same release rejects `[profiles.*]` tables inside `config.toml`,\nso the profile lives in its own file, `~/.codex/pgw.config.toml`:\n\n```toml\nmodel = \"privacy-gateway\"\nmodel_provider = \"pgw\"\n# Without these two, Codex warns \"Model metadata for privacy-gateway not found.\n# Defaulting to fallback metadata\": it knows no context window for an id that is\n# not an OpenAI model, so it guesses one and may truncate turns on its own.\nmodel_context_window = 65536      # the gateway's 256 KiB body limit is ~65k tokens\nmodel_max_output_tokens = 8192\n\n[model_providers.pgw]\nname = \"Privacy Gateway\"\nbase_url = \"https://privacy-gateway.kexi.dev/v1\"\nwire_api = \"responses\"\n# Only when the deployment is gated: Codex sends an env_key as `Bearer`,\n# which the Basic gate refuses, so the header goes here instead.\nhttp_headers = { \"Authorization\" = \"Basic <base64(user:pass)>\" }\n```\n\nThen `codex --profile pgw`. `GET /v1/models` advertises exactly one id,\n`privacy-gateway`: a caller selects the _fleet_, not the model behind it.\n\nOn that surface `instructions` is prepended to the `input` turns before masking, and the\nanswer comes back as one `message` item whose `content[0]` is an `output_text`. Codex\nhard-codes `stream: true`, so the reply is SSE: one delta, then\n`response.output_item.done`, then `response.completed`. A refused release is a terminal\n`response.failed` carrying the gateway's error code — never a completed turn. Codex's tool\ndeclarations are accepted and ignored: the fleet has no sandbox to run a tool in, and\nfabricating a call the model never made would be a command the caller executes.\n\n**Message mapping.** `system` and `user` contents are concatenated in order, separated by a\nblank line, into the one text the pipeline masks. `assistant` turns are dropped: they are the\nfleet's own prior output, already rehydrated in the caller's transcript, and feeding them back\nwould push raw values at the boundary the egress guard exists to hold. Multi-turn context is\ntherefore the caller's concatenation — each request is masked and vault-keyed independently,\nbecause [there are no sessions](#sessions-are-gone).\n\n**Extension field.** `choices[0].message.content` is the rehydrated answer and `id` is\n`chatcmpl-<request_id>`, so the evidence stays reachable from an OpenAI-shaped response. The\nprivacy facts the OpenAI schema cannot express travel in `x_privacy_gateway`: `request_id`,\n`trace_id`, `trust_tier`, `status`, `masked_prompt`, `withheld`.\n\n**Refusals** return an OpenAI error object with the status from the table above preserved and\nthe category findings attached — never a `200` whose content is an apology.\n\n**Streaming** (`stream: true`) emits one content chunk and then `[DONE]`. That is deliberate,\nnot a stub: the gates are fail-closed and the leak check runs on the _complete_ Core answer,\nso streaming tokens as they were produced would release text before the verdict that decides\nwhether it may be released at all. A refusal that arrives after the caller has rendered half\nan answer is not a refusal.\n\n## The MCP server\n\n`clients/mcp` exposes the fleet to any MCP client as three tools — `pgw_ask`, `pgw_evidence`\nand `pgw_verify` — so an agent can ask, read the audit document, and independently replay the\nattestation. Refusals arrive as structured results rather than thrown errors, so a model can\nexplain a privacy gate instead of retrying around it.\n\nBuild it once (`pnpm -r build`), then register it. Claude Desktop, in\n`claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"privacy-gateway\": {\n      \"command\": \"node\",\n      \"args\": [\"/absolute/path/to/all-things-agentic-hackathon/clients/mcp/dist/index.js\"],\n      \"env\": { \"PGW_GATEWAY_URL\": \"https://privacy-gateway.kexi.dev\" }\n    }\n  }\n}\n```\n\nClaude Code and Codex register the same binary:\n\n```bash\nclaude mcp add privacy-gateway \\\n  --env PGW_GATEWAY_URL=https://privacy-gateway.kexi.dev \\\n  -- node /absolute/path/to/clients/mcp/dist/index.js\n```\n\n```toml\n[mcp_servers.privacy-gateway]\ncommand = \"node\"\nargs = [\"/absolute/path/to/clients/mcp/dist/index.js\"]\nenv = { PGW_GATEWAY_URL = \"https://privacy-gateway.kexi.dev\" }\n```\n\nFull notes — what `pgw_verify` can and cannot check, and why a refusal is a result rather\nthan a thrown error — are in [`clients/mcp/README.md`](clients/mcp/README.md).\n\n## The Python client (language-agnostic consumption)\n\n`clients/python/pgw.py` is a single-file [PEP 723](https://peps.python.org/pep-0723/) script\nwhose only dependency is `httpx`. It exists to demonstrate a property of the design rather\nthan to be the supported SDK: **the gateway speaks ordinary JSON over HTTP**, so consuming\nthe fleet needs no SDK and no shared runtime with the agents — a Python script, curl, or any\nother language works the same way.\n\n```bash\nuv run clients/python/pgw.py ask \"text\"\nuv run clients/python/pgw.py evidence <request_id> [--json]\nuv run clients/python/pgw.py verify <request_id> [--base URL]\nuv run clients/python/pgw.py --gateway https://... ask \"text\"\n```\n\nThere is no `--session` option: the gateway mints one id per request and rejects a body\ncarrying `session_id`. The `approve` and `answer` commands are gone along with the human\napproval flow (see [Human approval, removed](#human-approval-removed) below). `just ask`,\n`just evidence` and `just verify-answer` wrap the same three commands.\n\n`--gateway` is a **top-level** option and must come _before_ the subcommand.\n\n`ask` prints the masked prompt (what the frontier model actually saw), the rehydrated answer,\nand the trust tier — which it **derives client-side** from the OKF `verified` field rather\nthan reading a server-supplied value. OKF SPEC §5.3 requires the tier to be derived and never\nstored, and a third client re-deriving it independently is what proves the property holds end\nto end. It exits `2` when the attestation failed, so a shell pipeline can react to a leak\nverdict.\n\n`evidence <request_id>` fetches the stored masked OKF document for one request.\n\n`verify <request_id>` is the replayable attestation check: it fetches the evidence document\nand both masked sources (the masked prompt and Core's tokenized response) the gateway serves,\nre-derives the leak-check verdict with a scanner **transcribed independently** — deliberately\nnot imported from the fleet's own attester, so the replay proves something rather than\nagreeing with itself by construction — and compares every digest the `attestation` block\nrecorded (`masked_prompt_sha256`, `core_response_sha256`, `verdict`) against what it\nrecomputes. `just verify-answer <request_id> [base]` wraps it.\n\n## Core Agent\n\n`agents/core` is the only service outside the trust boundary. It receives already-masked\nprompts over A2A, reasons over them with Gemini on Vertex AI, and echoes the placeholder\ntokens back untouched.\n\n- **Agent Card** is served at `/.well-known/agent-card.json` — the standard path exported as\n  `AGENT_CARD_PATH` by `@a2a-js/sdk` 0.3.x. (The older `/.well-known/agent.json` spelling is\n  not served.) RPC lives at `/jsonrpc` (JSON-RPC) and `/rest` (HTTP+JSON); `/healthz` answers\n  liveness probes.\n- **The card deliberately omits the system instruction.** ADK's derived card would embed the\n  full instruction text in the public skill description; since the card is fetched\n  unauthenticated, `src/server.ts` supplies an explicit card instead.\n- **Inbound guard** (`src/guard.ts`) re-scans every RPC payload for raw emails, phone numbers,\n  Luhn-valid card numbers and known credential formats, and answers `400\nunmasked_sensitive_data` if any survived masking. Its detectors are deliberately duplicated\n  rather than imported from the vault-side code: Core holding no vault-reachable dependency\n  _is_ the structural guarantee, and the subpath exports it is allowed to import do not\n  include one.\n- **No tools and no Firestore client.** Core cannot reach the vault even if its code tried.\n  Core's `package.json` does depend on the whole `@privacy-gateway/common` package — so the\n  package graph alone does not prove the boundary. The actual guarantee is **IAM**: Core's\n  service account has no Firestore role at all (see [Deploy](#deploy)). The subpath-export\n  argument in [Why this is more than \"regex before an API call\"](#why-this-is-more-than-regex-before-an-api-call)\n  is a second, independent line of defense on top of that, not a substitute for it.\n- **Logs** are single-line JSON with `request_id`; request bodies are never logged, and\n  findings record only the kind and length of a match, never the matched value.\n\nVertex AI is selected by environment, as ADK documents: `GOOGLE_GENAI_USE_VERTEXAI=true`,\n`GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION` (use `global` unless a region is required),\nplus ADC via `gcloud auth application-default login`.\n\n## A2A, precisely\n\nOnly **Gateway → Core** uses A2A: an Agent Card fetch followed by `message/send`. Gateway →\nSynthesis is plain authenticated HTTP, deliberately — the OKF document Synthesis returns is an\naudit artifact, and it must be retrievable without an LLM rephrasing it anywhere on the way\nback. Not all three agents are \"connected via A2A\" in the same sense:\n\n- **Gateway** exposes no Agent Card of its own. It only ever discovers Core's.\n- **Core** is the one real A2A server in the fleet: it serves an Agent Card and answers\n  `message/send`.\n- **Synthesis** mounts an A2A surface, but that surface only acknowledges an exchange — it does\n  not perform leak checking, release or OKF assembly over A2A. Those happen over the plain HTTP\n  route the Gateway actually calls.\n\n## Human approval, removed\n\nThe `DEFAULT_APPROVER` env var and the whole human-approval flow (`POST\n/v1/sessions/{id}/approve`, the `human:<id>` actor it minted) are gone. The public gateway\nauthenticates nobody, so a `human:<id>` actor minted from a UI click would name no one — and\npublishing it into `verified` would devalue the OKF `human-reviewed` tier, which is supposed to\nmean an identified person looked at the answer. The `packages/common` OKF library still\nsupports the generic trust-tier derivation (any `human:`-prefixed `verified.by` entry yields\n`human-reviewed`), because that derivation is part of the OKF contract itself — this product\nsimply never mints a `human:` actor. The UI's review-identity dimension always shows **\"review\nidentity: none\"**.\n\nThe UI shows **four separate dimensions**, never a single collapsed badge: policy verdict,\ndocument status, freshness, and review identity (always `none`). Collapsing them into one badge\nis what previously let \"PASS\" and \"Gemma flagged\" appear to agree when they did not; each is\nderived independently and displayed on its own. A blocked request is shown as its own outcome —\nnot hidden behind a generic error string.\n\n## Pseudonymization, not anonymization\n\nNothing this fleet does is anonymization or de-identification. Placeholders are a\n**pseudonym**: `⟦EMAIL_1⟧` discloses that a value exists, its category, and its equality with\nevery other `⟦EMAIL_1⟧` in the same document — an attacker who already suspects the underlying\nvalue can often confirm it from that alone. Beyond that, the masked text still carries\nsurviving quasi-identifiers the tokenizer does not touch — employer, location, date, role —\nand that residual context can permit contextual re-identification even though every detected\nidentifier was replaced before the prompt left the trust boundary. Treat every masked document\nas pseudonymous, not anonymous.\n\n## Deploy\n\nSee **[docs/DEPLOY.md](docs/DEPLOY.md)**. In short:\n\nGoogle Cloud resources are declared in Terraform (`infra/terraform/`); container images\nare built separately by Cloud Build. `just` remains the only command surface.\n\n```bash\njust tf-bootstrap                 # create the GCS state bucket (once; the only gcloud-made resource)\njust tf-init                      # initialise Terraform against that bucket\njust build                        # build and push the five images with Cloud Build\njust tf-plan gpu_enabled=false    # review the changes\njust tf-apply gpu_enabled=false   # apply everything except the GPU service\njust tf-apply                     # add the GPU-backed gemma-serving\njust urls && just health          # verify\njust tf-destroy                   # tear down (GPU billing stops first)\n```\n\n`gpu_enabled=false` skips the GPU-backed `gemma-serving` service, so the rest of the fleet\ncan be deployed without a GPU at all. The accelerator is **NVIDIA RTX PRO 6000**, not L4:\nGoogle declined the L4 quota request (regional exhaustion, 2026-08) and pointed at RTX PRO\n6000, which is auto-granted per region, so no quota wait applies.\n\nCore's service account deliberately has **no** Firestore role; the Gemma serving endpoint\nuses internal-only ingress; service-to-service calls authenticate with ID tokens.\n\n### Cost, and the automatic kill switch\n\nIdle costs **$0** — every service scales to zero. With everything warm the fleet runs about\n**$1.64/hour**, essentially all of it the GPU-backed `gemma-serving`. The one realistic way\nto lose money here is forgetting the teardown: left up for a day, that is **~$39**.\n\nSo a forgotten teardown is handled automatically rather than by an email nobody reads at 3am.\nA **¥15,000 (~$95) Cloud Billing budget** publishes every threshold crossing (50% / 80% / 100%) to a\nPub/Sub topic, whose push subscription calls a small `kill-switch` Cloud Run service. At 100%\nit removes the `allUsers` invoker binding from `gateway-agent`, holds `gemma-serving` at zero\ninstances via Cloud Run manual scaling, and strips the fleet's invoker rights on\n`gemma-serving` — all idempotent, so Pub/Sub redelivery is harmless. Below 100% it only\nlogs. Restore with `just restore-after-kill` once the underlying spend is fixed.\n\nNote that a cost gate deliberately does **not** fail closed the way this fleet's disclosure\ngates do: a notification it cannot parse is logged and ignored, because taking the demo\noffline over a malformed message would itself be the outage. Creating the budget needs\n`roles/billing.costsManager` **on the billing account** (project Owner is not enough); see\n[docs/DEPLOY.md](docs/DEPLOY.md) § \"Automatic cost kill switch\".\n\n## Environment variables\n\nEvery variable below is validated with zod at startup (`packages/common/src/config.ts`); an\ninvalid value stops the process rather than failing mid-request.\n\n| Variable                     | Default                     | Purpose                                                                                             |\n| ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------- |\n| `GOOGLE_CLOUD_PROJECT`       | —                           | GCP project for Vertex AI and Firestore                                                             |\n| `GOOGLE_CLOUD_LOCATION`      | `us-central1`               | Vertex AI location. Core is deployed with `global` — see the note below                             |\n| `GOOGLE_GENAI_USE_VERTEXAI`  | `1`                         | route the Gemini SDK through Vertex AI                                                              |\n| `GEMINI_MODEL`               | `gemini-3.5-flash`          | Core's model id — **see the note below**                                                            |\n| `GEMMA_BASE_URL`             | `http://localhost:11434/v1` | OpenAI-compatible Gemma endpoint                                                                    |\n| `GEMMA_MODEL`                | `gemma4:12b`                | Gemma model tag                                                                                     |\n| `GEMMA_API_KEY`              | `ollama`                    | placeholder key for the OpenAI-compatible API                                                       |\n| `CORE_BASE_URL`              | `http://localhost:8082`     | Core service base URL (Agent Card resolved under it)                                                |\n| `SYNTHESIS_BASE_URL`         | `http://localhost:8083`     | Synthesis service base URL                                                                          |\n| `A2A_TIMEOUT_SECONDS`        | `120`                       | per-hop timeout                                                                                     |\n| `A2A_PUBLIC_URL`             | —                           | public base URL written into the Agent Card                                                         |\n| `A2A_HOST` / `A2A_PROTOCOL`  | `localhost` / `http`        | host and scheme used when no public URL is set                                                      |\n| `VAULT_BACKEND`              | `memory`                    | `memory` or `firestore`                                                                             |\n| `VAULT_COLLECTION`           | `token_vault`               | Firestore collection for the vault                                                                  |\n| `ANSWER_COLLECTION`          | `gateway_answers`           | Firestore collection for OKF answers                                                                |\n| `VAULT_TTL_SECONDS`          | `3600`                      | vault lifetime; equals each answer's `stale_after`                                                  |\n| `MAX_BODY_BYTES`             | `65536`                     | max request body (was a 10 MB literal; a prompt is prose)                                           |\n| `REQUEST_DEADLINE_SECONDS`   | `60`                        | end-to-end deadline for one `/v1/ask`                                                               |\n| `RATE_LIMIT_PER_MINUTE`      | `20`                        | per-IP quota; `0` disables it                                                                       |\n| `REHYDRATE_ALLOW_CATEGORIES` | unset (withhold all)        | comma-separated categories re-enabled for rehydration — see [Disclosure policy](#disclosure-policy) |\n| `WEB_DIR`                    | `./web/dist`                | built SPA served by the Gateway                                                                     |\n| `PORT`                       | `8081`                      | injected by Cloud Run                                                                               |\n| `LOG_LEVEL`                  | `INFO`                      | structured JSON logs, always PII-masked                                                             |\n| `OTEL_ENABLED`               | `0`                         | export OpenTelemetry spans (Cloud Trace, or console)                                                |\n| `OTEL_SERVICE_NAME`          | per-agent                   | overrides the service name on spans                                                                 |\n| `VITE_GCP_PROJECT`           | —                           | project id baked into the UI's console links                                                        |\n\n> **Note on `GEMINI_MODEL` and the global endpoint.** The hackathon requires \"Gemini 3.5 or\n> newer\". Model id strings change as versions reach GA, and the id is therefore never\n> hard-coded in agent code — it is read from `GEMINI_MODEL`, with `gemini-3.5-flash` as the\n> documented default, verified against Vertex AI with a live `generateContent` call.\n>\n> `gemini-3.5-flash` is published **only on the global Vertex endpoint**: the `us-central1`\n> regional endpoint 404s for it (probed 2026-08-28). Terraform therefore deploys the Core\n> service with `GOOGLE_CLOUD_LOCATION=global` while every other resource — Firestore, Cloud\n> Run, Artif",
  "bytes": 60000,
  "sha": "bb4338ef776f2de01960e49e26408a6bbe37eac63fe9fb9bfbeda8c378b338a5",
  "repo_slug": "kexi/privacy-gateway",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_kexi_privacy_gateway_knowledge_index_md_32b5556c/readme"
}