# Gatekeeper Architecture Brief

> **Purpose.** A one-page, reviewer-oriented summary of Gatekeeper's real data flow,
> data lifecycle, deployment models, subprocessors, and failure behavior. Every claim
> is traced to current code or configuration. Statements are labeled **REAL / CURRENT**
> (implemented and verified) or **ROADMAP / PROPOSED** (designed, not shipped). This brief
> is consistent with, and defers to, the authoritative source of truth:
> [`docs/architecture/sw-d-storage-and-judge-fail-open.md`](../docs/architecture/sw-d-storage-and-judge-fail-open.md)
> (SW-D). It claims no formal compliance status and invents no control.

---

## 1. Architecture and Data Flow

Gatekeeper is a drop-in reverse proxy between a customer application and OpenAI/Anthropic.
Every proxied request runs a fixed-order pipeline in `api/main.py` and returns early on the
first block. **Detector order matters:** the jailbreak check (Step 1) runs **before** PII
redaction (Step 2). The diagram below reflects the **actual** order — including the honest
fact that the internal judge receives **raw, pre-redaction** text today.

```mermaid
flowchart TD
    A["Customer / Application<br/>(X-Gatekeeper-Key, X-Workflow-Tag,<br/>X-Actor-ID, chat messages, upstream key)"] --> P["Gatekeeper Proxy<br/>/proxy/openai · /proxy/anthropic"]
    P --> RAW["full_text = raw request (pre-redaction)<br/>main.py:896"]

    RAW -->|"Step 1 (raw text)"| JB["Jailbreak detect_jailbreak<br/>main.py:921"]
    JB -->|"score 60 / escalated → judge"| JUDGE["Gatekeeper Judge slow_check<br/>gpt-4o-mini · OpenAI<br/>RAW snippet embedded verbatim<br/>jailbreak_detector.py:390,520"]
    JUDGE -.->|"fail-open on error/timeout"| JB

    JB -->|"not blocked"| PII["Step 2: PII/PHI Detection<br/>Presidio + secret regexes<br/>scan_messages → detect_pii<br/>main.py:1110"]
    PII --> CLEAN["Redaction → cleaned_text<br/>[REDACTED_TYPE] placeholders"]
    CLEAN --> POL["Policy enforcement<br/>soc2 / hipaa"]
    POL -->|"cleaned text only"| MP["Model Providers<br/>OpenAI / Anthropic (customer key,<br/>in-memory pass-through)"]

    RAW --> AUD["Audit Logging log_audit"]
    CLEAN --> AUD
    AUD --> DB["Neon / Postgres audit_logs<br/>original_text (raw) + cleaned_text<br/>+ metadata + exceptions_log"]

    JB -->|"blocked"| EX["explain_block (gpt-4o-mini)<br/>+ Slack alert (metadata only)"]
    EX --> AUD
```

**Discrepancy flagged (F3b):** the TP-2 target architecture depicts the judge receiving
*cleaned/redacted* text. **Current code does not do this** — jailbreak detection (and its
judge) runs on raw `full_text` before any PII pass. Judge-input redaction is a queued,
**post-freeze ROADMAP** item, not shipped behavior (see §5 and §6). The diagram above shows
the real behavior; it does not overstate the redaction boundary.

---

## 2. Data Lifecycle

**What enters.** The proxy receives the customer's chat messages, the governance headers
`X-Gatekeeper-Key` (app identity), `X-Workflow-Tag`, and `X-Actor-ID`, plus the customer's
own upstream provider API key. Messages are concatenated into one `full_text` string
(`main.py:896`).

**What is processed.** In fixed order: rate limit → **jailbreak** (`detect_jailbreak`, may
call the judge) → IOC threat feed → custom GitOps rules → **PII/PHI detection & redaction**
(`detect_pii`) → policy evaluation (`soc2`/`hipaa`). Governance headers select the enforced
policy (`resolve_policy_for_workflow`).

**What leaves.**
- **Model providers (OpenAI / Anthropic):** on the proxy forward, receive **cleaned/redacted
  text only** (`cleaned_messages`), with the customer's key passed through in memory
  (`main.py:1192`, `1239`, `1664`, `1713`). **Verified.**
- **Gatekeeper judge (`slow_check`, gpt-4o-mini via OpenAI):** receives **raw, pre-redaction
  text** — the prompt is embedded verbatim in the judge request
  (`jailbreak_detector.py:390`, `:520`). This is the one path where un-redacted text reaches
  an external provider today. **Verified — this is a gap, not the target state.**
- **Block explainer (`explain_block`, gpt-4o-mini):** on a block, receives a raw 200-char
  snippet and returns a plain-English reason (see §6).

**What is stored.** One `audit_logs` row per request in PostgreSQL, written by `log_audit`:
- `original_text` — **raw, pre-redaction** request text (still contains any PII/PHI the caller
  sent). `database.py:137`.
- `cleaned_text` — post-redaction text forwarded upstream; `NULL` on block-before-forward
  paths. `database.py:138`; block rows set `cleaned_text=None` (e.g. `main.py:956`).
- Audit **metadata / decision**: detected entity types, jailbreak score, block reason, policy
  version, workflow tag, actor identity, `exceptions_log` (JSONB) — including `judge_fallback`
  fail-open events.
- The finer `pii_redaction_logs` table stores **lengths only**, no text.
- **`original_text` is stored, not discarded** — it is never sent to a third party by
  Gatekeeper, but it is persisted verbatim. There is **no** flag to disable this short of
  removing `DATABASE_URL` (which disables all auditing). Evidence exports
  (`evidence_formatter.py`) emit only `{TYPE: count}` maps and never raw text.

**Retention.** **No retention mechanism exists.** `audit_logs` is append-only; a repo-wide
search found no TTL, purge, expiry, or deletion job for it — rows (including `original_text`)
persist **indefinitely** until manually deleted (SW-D §1.4). A retention window
(`AUDIT_RETENTION_DAYS` + purge) and a `STORE_ORIGINAL_TEXT` opt-out are **ROADMAP**
(`POST_FREEZE_BACKEND_QUEUE.md` items 1–2), not implemented.

---

## 3. Deployment Models

Both models run **identical application code**; they differ only by where the single
`DATABASE_URL` points.

**Customer-deployed (self-hosted).** The customer runs the Gatekeeper service in their own
infrastructure and points it at **their own PostgreSQL**. All audit data — including raw
`original_text` — stays within the customer's boundary. Neon and Railway are not in the path.
Redacted text still leaves to the customer's chosen model provider; raw judge-path text still
reaches OpenAI when the judge is invoked.

**Hosted (Gatekeeper-operated).** The service points at Gatekeeper's managed PostgreSQL
(**Neon**) and runs on **Railway** compute. Under this model, **customer request text —
including raw `original_text` that may contain PHI — is stored in Gatekeeper's Neon
environment.** This is a material fact for a covered entity: under the hosted model Gatekeeper
holds customer PHI at rest.

**BAA position.** There is **no** Business Associate Agreement, DPA, or subprocessor register
in the repository. The BAA stance is an open **founder/legal decision** — not decided, not
available. This brief makes **no** claim that a BAA exists. (Customer-deployed keeps PHI in
the customer's boundary, which may reduce BAA scope, but does not itself constitute a BAA.)

---

## 4. Subprocessors / Fourth Parties

No formal subprocessor register/DPA exists in the repo (ROADMAP — pending founder decision).
The table below reconstructs each party's role strictly from documented data flows. Providers
do **not** all receive the same data.

| Subprocessor | Role | Data Received | Data Not Received / Notes |
|---|---|---|---|
| **OpenAI** | Model provider for `/proxy/openai`; **also** the internal jailbreak **judge** and block **explainer** (`gpt-4o-mini`) | **Redacted** text on the proxy forward (customer key, in-memory). **Raw pre-redaction** prompt snippet on the judge/explainer path, using Gatekeeper's own key. | Stored `original_text` audit column is never exported. **Nuance:** raw text does reach OpenAI on the judge path today (ROADMAP: judge-input redaction). |
| **Anthropic** | Model provider for `/proxy/anthropic` | **Redacted** text only, customer key passed in memory. | Not used for detection, judge, or explainer. Never receives raw `original_text`. |
| **Neon** (PostgreSQL) | Managed audit database — **hosted model only** | Audit rows (**including raw `original_text`**) and governance metadata, under the hosted model. | Under customer-deployed, Neon is not used (customer's own DB holds data). Customer provider API keys are never stored. |
| **Railway** | Application hosting/compute — **hosted model only**; terminates TLS | Request text passes through compute **transiently in memory** during processing. | Under customer-deployed, Railway is not in path. The Railway app process does not durably persist text (durable storage is the DB). |
| **Vercel** | Hosting for marketing site, dashboard, live sample-evidence packet, and public demo; terminates TLS | Demo text a visitor submits via the public demo route (forwarded to the backend). | Production audit DB does not live on Vercel; demos use synthetic data by policy. |

---

## 5. Failure and Availability Behavior

**Judge fail-open (REAL / CURRENT).** When the jailbreak judge (`slow_check`) is unavailable —
timeout (explicit `timeout=10.0`), network/API error, missing `OPENAI_API_KEY`, malformed body,
or any exception — a single `except Exception` returns `verdict: NO, confidence: 0`. Because a
block requires `VERDICT: YES && confidence >= 70`, **a failed judge never blocks; the request
proceeds (fail-open).** This is policy-independent — HIPAA behaves identically to SOC-2.
Evidence: `jailbreak_detector.py:446-465`, `:416`; SW-D §2.1–§2.4. The deterministic fast-path
block for obvious jailbreaks (2+ regex matches) is unaffected and still blocks without the judge.

**Audit trail on fail-open (REAL / CURRENT).** Not silent — three layers:
1. A loud ASCII stdout line: `[ALERT] JUDGE FAIL-OPEN [TIMEOUT|ERROR] ...`.
2. A structured per-request `gatekeeper.b2` JSON detector log.
3. A persisted `judge_fallback` event appended to that request's `exceptions_log`
   (JSONB on the allowed-path `audit_logs` row), surfaced in the evidence packet
   (`main.py:926-935`, `:1396-1403`; `database.py`).

**Slack alerting (REAL / CURRENT).** On **any block**, a fire-and-forget Slack alert fires
(`asyncio.create_task(send_slack_alert(...))`) so it never slows the proxy. Alerts are
rate-limited (max 1 per app+reason per 60s) and contain **metadata only** — app name, time,
block reason, and the plain-English explanation. The **original prompt content is never
included** (`slack_alerts.py:64`). Webhook URLs are Fernet-encrypted at rest. Slack failures
are swallowed and never crash the proxy.

**Fail-closed (ROADMAP / PROPOSED).** There is **no** fail-closed (block-on-error) mode today
and **no** config flag to enable one. A scoped, opt-in `JUDGE_FAIL_CLOSED_POLICIES` mode for
high-assurance workflows is designed (SW-D §2.7; `POST_FREEZE_BACKEND_QUEUE.md` item 4) but
**not wired**. Do not read the current posture as anything other than fail-open.

---

## 6. Verification Notes

**F3b — Does the judge receive cleaned/redacted text?**
**Status: VERIFIED — the judge receives RAW, pre-redaction text (not cleaned).**
- `api/main.py:896` — `full_text` built from raw messages.
- `api/main.py:921` — `detect_jailbreak(full_text, http_client)` (Step 1, before redaction).
- `api/main.py:1110` — `scan_messages(...)` PII redaction runs **after** the judge.
- `api/app/jailbreak_detector.py:520` — `slow_check(text, http_client)` receives that raw text.
- `api/app/jailbreak_detector.py:390` — raw `text` embedded verbatim in the judge prompt.

F3b is **documentation-only** and confirms this behavior; it does not change it. Judge-input
redaction (send cleaned text to the judge) is **ROADMAP** — `POST_FREEZE_BACKEND_QUEUE.md`
item 5 ("Current state: NOT implemented — the judge receives raw `full_text`"). The TP-2 target
diagram (cleaned → judge) describes the intended future state, **not** current code; the gap is
flagged in §1.

**`explain_block` sibling check — can the blocked path expose raw/original text?**
**Status: VERIFIED (with one honest nuance).**
- **What `explain_block` receives:** `block_reason` and `request_snippet` (= raw `full_text`,
  e.g. `main.py:940`); it sends only `request_snippet[:200]` to `gpt-4o-mini`
  (`api/app/explainer.py:31`).
- **What `explain_block` returns:** only a 1–2 sentence plain-English explanation string; on
  failure, a generic message plus `block_reason`. Its **return value contains no raw text**
  (`explainer.py:60-65`).
- **Nuance 1 (external):** `explain_block` (like the judge) sends a **raw 200-char snippet** to
  OpenAI — same raw-text-egress gap as F3b, on the block path.
- **Nuance 2 (caller echo):** the surrounding block **response body** returns
  `original_text: full_text` to the caller (`main.py:990`). This is the caller's **own**
  request echoed back to the same caller — not an outbound export to a third party — but it is
  not a redacted response.
- **Slack path is clean:** the alert built from `explain_block`'s output carries metadata only,
  never prompt content (`slack_alerts.py:64`).

---

### Known gaps (stated honestly)

- **Judge/explainer raw-text egress to OpenAI** — real today; judge-input redaction is ROADMAP.
- **`original_text` stored indefinitely, plaintext, not disableable** — no retention/TTL and no
  `STORE_ORIGINAL_TEXT` flag (ROADMAP).
- **No fail-closed mode** — fail-open only today (ROADMAP).
- **No BAA / DPA / subprocessor register** — pending founder decision.

*No formal compliance status (HIPAA/SOC 2) is claimed. The compliance mapping is
evidence, not proof of formal compliance status.*
