Gettier Architecture — v0.1¶
Distilled from the founding design conversation (2026-07-10 → 2026-07-15). This document is
the system-level source of truth: planes, trust boundaries, storage, the turn state machine,
and deployment. The two frozen interfaces live in docs/contracts.md; the invariants every
session must obey live in CLAUDE.md. When this document and code disagree, this document
wins until it is deliberately amended.
1. What Gettier is, in one paragraph¶
An assurance and observability platform for AI-generated software. It records the load-bearing factual premises ("lemmas") a consequential AI action depends on, verifies them against live sensors on the customer's systems, gates the action until they are measured, told, or explicitly waived, and logs the whole cycle to a per-tenant, hash-chained audit log. It is the epistemologists' No False Lemmas condition, mechanized: tests verify outcomes; Gettier verifies reasons.
Two customer-facing surfaces, one engine:
- Gettier for Agents (
packages/cli) — agent installation, MCP tools, local sensor execution, CI and pull-request gates. Agent integrations must never require the customer to operate a Gettier server. - Gettier for Runtime (
packages/node) — SDKs for deployed AI applications: model-call tracing, batching, redaction, release correlation. Runtime observability fails open; only explicit enforcement APIs may fail closed.
The grounding proxy is a third mechanism, not the product: it sits between an
OpenAI/Anthropic-compatible client and any LLM (adoption = one base_url change) and makes
the gate unavoidable rather than cooperative. The harness is a fourth — it replays frozen
scenarios and scores knowing pass / catch / lucky pass / miss.
Do not treat any one of them as the whole product. They share the core; the rule of thumb is that any feature existing in only one shell is a smell (CLAUDE.md rule 1).
2. The four planes¶
The system is four planes with explicit trust boundaries between them:
-
Control plane (our cloud): stateless app fleet running the turn state machine; queue-backed workers for the heavy async work (scenario replays, distillation runs, report generation) so a 30-scenario × 4-model replay never starves interactive proxy traffic — the queue is also the natural per-tenant rate-limit point; Postgres hosting the pooled-RLS schema and dedicated tenant databases side by side; object storage for exports and chain snapshots; analytics ETL kept physically separate from tenant data (telemetry is an explicit, consent-scoped export, never incidental query access).
-
Data plane (customer side): the sensor agent (outbound-only, executes registry sensors by ID), the narrow proxy relay, and optionally their local model. The relay sends redacted, scoped telemetry to Gettier's hosted dashboard. The sensors were always going to live customer-side — a cloud SaaS cannot
docker statsa customer's server — so the cloud is, in a real sense, the control plane over a data plane the customer operates. -
Model plane: upstream LLM APIs or customer-hosted inference (Ollama etc.). Always treated as an untrusted component — it receives curated context and returns text that is parsed, never executed. Nothing the model says takes effect except through the deterministic gate.
-
Client plane: anything speaking the OpenAI/Anthropic wire format via
base_url(Continue, Cline, Roo, Zed, CLIs) — plus, later, the VS Code extension over a local event API (see §7).
Glue rules that hold the planes together: one core library with thin shells (proxy, worker, agent, harness, web — plus the extension, §7, when it lands); one choke point for tenant-scoped data access; one hosted control plane with customer-side relays. Every request path is: edge resolves tenant from subdomain → tenant resolver issues scoped connection → state machine runs → audit chain appends. No component below the resolver ever sees a tenant ID as a query parameter it must remember to use.
The public marketing site is deliberately outside those product shells. It lives in
packages/landing as a separately deployed, statically generated Next.js application: it has
no ledger dependency, no tenant resolver, and no dashboard credentials. This separation keeps
public caching, crawlability, analytics, and SEO headers away from the authenticated control
plane. docs/design/landing.html (founding commit c7bd48d) is the historical visual source;
the shipped packages/landing/app implementation is canonical. Shipped
claims remain governed by the root README's status section.
3. The six trust boundaries¶
Two unusual properties dominate the threat model: the ledger ingests customers' infrastructure state (radioactive data), and sensor output flows into prompts (injection surface). Hardening is organized around six boundaries:
3.1 Client → wrapper¶
API keys are random, prefixed (gt_live_…), stored hashed (SHA-256 — they're high-entropy,
no bcrypt needed), shown once, revocable, scoped per-tenant with least privilege (a key
that can proxy turns must not manage sensors). Dashboard auth is standard OIDC; SSO/SAML at
the enterprise tier. Per-tenant rate limits and quotas enforced at the edge and again at
the queue — replay is a compute amplifier, so quotas are a security control, not just
billing.
3.2 Wrapper → model¶
BYOK upstream keys are envelope-encrypted (KMS-wrapped data keys), decrypted only in memory at call time, never logged, never in the ledger. Structured logging with an explicit allowlist of loggable fields — the default for anything prompt- or fact-shaped is redacted, because logs must not become a second, unprotected copy of the radioactive data.
3.3 Agent ↔ cloud — the boundary that can kill the company if botched¶
The agent must never be a remote shell. Outbound-only mTLS, no listening ports. The cloud cannot push arbitrary commands — it invokes sensors by ID from a registry that lives customer-side, is version-pinned, and requires local approval to change. Sensor definitions are declarative: command template + typed parameters validated (type + allowlist) before substitution, substitution at argv level, never string-concatenated shell. Execution under a least-privilege user with timeouts, output-size caps, and an explicit egress allowlist. Design target: a fully compromised Gettier cloud can, at worst, run the sensors the customer already approved — nothing else. Ship the agent with an SBOM and signed releases from day one; this is the component security teams will decompile.
Sensors may also declare ambient: true (Contract 2 v0.5), which sweeps them on every
consequential turn regardless of what the task mentions. Keyword-scoped matching cannot
reach a premise no ticket names — the database role the app authenticates as, the runtime
it deploys onto — and both are load-bearing for any change. An ambient sensor must be
read-only: one that mutates would mutate every turn, so the registry loader refuses it.
3.4 Fact pipeline — the radioactive core¶
Every fact passes a redaction stage before the ledger write: secret pattern matching (key formats, tokens, connection strings) plus entropy scanning, with redactions logged as events so users see something was caught. Facts carry a sensitivity class; env-var-type sensors default to name-only capture with values masked unless explicitly allowlisted. Equally important: sensor output is untrusted input to the model — a log line can contain adversarial text — so facts are injected inside clearly delimited data framing, the model is instructed that fact content is data-never-instructions, and the real defense is that nothing the model says executes without the gate. The gate is not UX; it is the prompt-injection containment.
3.5 Tenant isolation¶
RLS policies on every pooled table; the tenant resolver sets SET app.tenant_id = … at the
connection choke point; a forgotten filter in application code returns zero rows, not
another tenant's data. The part most teams skip: adversarial CI — a suite that runs as
tenant A and attempts every query path against tenant B's data, executed against both
pooled and dedicated backends on every merge. Isolation you don't continuously test
regresses silently.
3.6 Audit chain¶
Append-only enforced by database trigger (not application discipline), per-tenant hash
chain (hash = H(entry ∥ prev_hash), prev_hash scoped to tenant), entries signed with a
per-tenant key held in KMS, periodic Merkle-root snapshot to object storage
(chain_snapshots — this is the whole blockchain seam; see §8) with optional external
anchoring (OpenTimestamps-style) as an enterprise checkbox. Ship the open-source verifier
with it — a tamper-evident log nobody can independently verify is marketing, not security.
Platform floor (beneath all six): TLS everywhere, encryption at rest, distroless/ non-root containers, dependency + container scanning in CI, least-privilege IAM per service, tested per-tenant backup restores, structured incident logging. Start the SOC 2 evidence trail (access reviews, change management) from month one even though the audit is a year out — retrofitting evidence is the expensive part.
4. Storage model: pooled + dedicated, with SQLite for development and D1 at the edge¶
The AWS-documented pooled/siloed hybrid, chosen deliberately over pure database-per-tenant (self-serve individuals in volume make a per-tenant fleet an operational liability) and over pure shared-schema (radioactive data, per-tenant audit chains, export-as-feature):
- Pooled tier (individuals, small teams): one shared schema,
tenant_idon every row, isolation enforced by Postgres RLS in the database, not app-code WHERE clauses. - Dedicated tier (enterprise, regulated): database-per-tenant on shared Postgres instances, sold as a feature — "your data in its own database, exportable as a file, independently verifiable audit chain." Few enough tenants that fleet management stays trivial. Not instance-per-tenant; that's a later enterprise conversation.
- Local development/offline tests: a single-tenant SQLite file implementing the same interface. This is an adapter for contributors and relay resilience, not a product tier.
- Experimental edge adapter: D1 remains implemented for the compatibility shell in
packages/worker, but it is not a v1 production backend and has no deployment workflow.
The discipline that makes this one design instead of three: application code never knows which model it's in. The tenant resolver returns "a connection scoped to one tenant" — behind it, that's a pooled connection with RLS context set, a dedicated database, or a local SQLite file. The experimental D1 adapter obeys the same interface but is not part of the hosted topology. Promotion pooled → dedicated is a data copy (pg_dump filtered by tenant — the same machinery as the export feature) plus a routing-table update, not a re-architecture.
Core tables, one job each:
- facts — the ledger. Statement, provenance (
measured|told|believed), source detail (sensor invocation hash or message ID), timestamp, TTL/invalidation, status (active|stale|contradicted). Mutable by design — facts about a live system must die when the system changes ("port 3000 bound by PID 4412" lives minutes; "we deploy on Ubuntu 24" lives months). - sensors — registry mirror: which fact categories each sensor can verify, so declared
lemmas can be matched to verification (matching rules in
docs/contracts.md). - distilled_knowledge — durable, editable store of learned truths injected as context. Normal CRUD, human-reviewable. Immutability is an anti-feature here.
- audit_log — append-only hash chain (§3.6). Every turn appends: facts injected, lemmas declared, sensor outputs + hashes, waivers, gate decision.
Conflict rule (a rule, not vibes): when a sensor reading contradicts an active fact, the
sensor wins and the old fact flips to contradicted — but the contradiction is surfaced to
the model in the next injection ("previously believed X, now measured Y"), because in
debugging the delta is often the diagnosis. Silent overwrite loses the signal (CLAUDE.md
rule 8).
Costs accepted knowingly: CI runs the suite against pooled and SQLite (and dedicated
for isolation tests); a migration runner that iterates dedicated tenants, handles partial
failure, and reports stragglers; cross-tenant analytics via ETL, never GROUP BY on tenant
data; dormant-tenant reaping is a DELETE WHERE tenant_id = … in pooled.
5. The turn state machine¶
The most consequential design decision: a passive proxy cannot do this job. By the time a model has produced its answer, the assumptions are baked into the diff — you can only block or annotate, which throws away the work. The proxy owns the turn. To the client it is the model (one request in, one streamed response out); inside, one user request fans out into a choreographed sequence:
- Route. Not every turn needs the full machine. A cheap classifier — heuristics at
first: does the turn propose code changes or system actions? — routes chitchat and
read-only questions straight to a single pass-through call, reserving the two-pass dance
for turns classified
consequential, where being wrong costs something. This router is what protects the token-economics metric. - Inject. Prepend structured context: active facts from the ledger, relevant distilled knowledge, and the declaration contract. Injection is idempotent — replace-on-each-turn, not append — because clients resend full histories; the ledger, not the transcript, is the source of truth.
- Declare (plan pass). The model is called — not to answer, but to produce a plan and
declare load-bearing lemmas, enforced mechanically via forced tool use
(
declare_lemmas,tool_choicerequired; schema indocs/contracts.md). The model physically cannot respond without emitting parseable premises. This pass never streams. Compliance ladder for models that can't do forced tool use: grammar-constrained decoding (llama.cpp/Ollama structured outputs) → schema-with-retry → degraded-mode extraction by a second small model. The harness's compliance-rung metric decides which rung each model lives on. - Verify. Diff declared lemmas against the facts table. Provenance claims are audited
(unearned
measured/tolddowngraded tobelieved— the miscalibration is itself recorded).believed+ load-bearing + sensor-matchable → dispatch the sensor (locally or via the customer-side agent), write the result to the ledger withmeasuredprovenance. Unverifiable load-bearing lemmas → surfaced to the user as one compact confirmation — Confirm / Correct / Waive — before any solution exists; the answer writes atoldfact or an explicit waiver to the chain. - Ground (answer pass). The model is called again — same conversation plus verification results injected as tool results ("you assumed X; sensor measured Y"). Now it produces the actual answer, streamed to the user. Contradictions arrive before the fix is authored, not after it's wrong; in debugging, the false premise usually is the diagnosis.
- Release + log. The gate decision and the whole cycle — injected facts, declarations, sensor outputs and their hashes, confirmations, waivers — append to the audit chain.
The turn is a small graph, not a line: inject → declare → verify → (confirm?) → ground → log, with the wrapper as deterministic orchestrator and the model as a component invoked multiple times inside one user-visible exchange. The user experiences a "checking assumptions…" beat (a trust feature — show what is being verified) followed by a normal streaming answer; cost is one extra model call per gated turn.
The gate is a policy, not a constant. "Block until verified" for destructive-adjacent actions; "verify in parallel and annotate" for medium stakes; "log only" for exploration. Per-tenant, per-action-class policy table. The waiver-rate metric tells us, per customer, whether the policy is tuned right — rising waivers is the leading indicator of the gate dying socially (the UAC-dialog failure) even while it works technically. And per CLAUDE.md rule 5: the gate lives in deterministic wrapper code that parses structured output — a model "promising" compliance in prose is non-compliance. This is doubly true for small local models, which agree to discipline in prose and violate it in the same response.
6. Deployment topology¶
Superseded in part by ADR 0005
This section describes the founding design, in which per-tenant subdomains were the
routing identity. The shipped v1 topology is a fixed set of hostnames with one owner
each — see the table below and .claude/decisions/0005-hostname-ownership.md. Tenant
resolution happens after authentication, from the credential, not from the hostname.
Wildcard per-tenant subdomains remain a possible enterprise feature, not the v1 plan.
The founding design made subdomains routing identity for everyone: acme.gettier.dev as
a wildcard DNS record, wildcard TLS cert, and a routing table — the hostname as tenant
identity, resolved at the edge before a request touches app code, feeding the choke point.
Vanity CNAMEs are an enterprise tier feature. Compute isolation is a separate axis, an
isolation ladder sold, not defaulted:
- Shared compute + pooled DB (free/individual)
- Shared compute + dedicated DB (business/enterprise)
- Dedicated K8s namespace with resource quotas (regulated buyers)
- Contract-specific BYOC — considered only when a regulated enterprise contract justifies the operational fork; it is not a public v1 plan
Container-per-tenant by default is explicitly rejected: it recreates the fleet-management problem at the compute layer (N deployments per release, version skew, idle pods). The app tier is stateless; tenant state lives entirely in the data layer, which is where isolation was already built.
Concrete topology: edge LB with wildcard cert and host-header routing → stateless app
containers (the proxy state machine), horizontally scaled, nothing tenant-shaped about
them → queue-backed worker tier (replays, distillation, reports; per-tenant rate limiting)
→ data tier: Postgres instance(s) hosting pooled schema + dedicated DBs side by side,
analytics ETL to a separate store, object storage for exports and chain snapshots.
An experimental edge compatibility shell remains in packages/worker. It runs the same wire
contract over D1, but cannot execute sensors and therefore has no production deployment path.
Cloudflare's production responsibility is the static marketing site; the hosted control and
model data planes run on AWS.
Canonical v1 hostnames¶
One owner per hostname (ADR 0005). A Cloudflare route matches before any origin, so a shared hostname would shadow rather than share — silently.
| Hostname | Serves | Runs on |
|---|---|---|
gettier.io |
landing page | Cloudflare Workers Static Assets |
www.gettier.io |
redirect to apex | Cloudflare landing Worker |
docs.gettier.io |
this documentation site | Cloudflare Workers Static Assets |
app.gettier.io |
dashboard, auth, billing | AWS, hosted chart (-web) |
api.gettier.io |
dashboard and SDK API, telemetry ingestion | AWS, hosted chart (-web) |
llm.gettier.io |
grounding proxy — what a customer sets as base_url |
AWS, hosted chart (-proxy, proxy.enabled) |
Hosted-only for v1; there is no advertised self-hosted production path.
Customer side: the narrow proxy and outbound-only sensor agent. They keep model credentials
and infrastructure access local, redact evidence, and send scoped telemetry to the hosted
control plane. The authenticated dashboard is the canonical history and visibility surface.
See docs/hosted-control-plane.md and ADR 0002.
7. The VS Code extension seam (later, but the API is designed now)¶
The extension is the wrapper's surface, not its client — no me-too chat panel; clients
already reach the proxy via base_url. Three jobs, in priority order: (1) native
confirmation UX for the gate (quick-pick: Confirm / Correct / Waive — one keystroke,
out-of-band from chat, logged to the chain); (2) the ledger made visible (sidebar with
provenance badges, freshness, right-click → invalidate, contradiction events); (3) the
editor as a sensor (open/dirty files, test results, LSP diagnostics, git state, debugger
position) — the extension becomes part of the data plane, a peer of the agent.
What this costs now: the proxy exposes a small local event API — subscribe to ledger events (SSE/WebSocket), answer confirmation requests, register editor sensors — designed alongside the state machine even though the extension ships later. Retrofitting an eventing surface onto a request/response proxy is the annoying version. The extension itself holds no logic: thin shells, one engine.
8. Decisions settled (do not relitigate)¶
- No blockchain. Tamper evidence = hash chain + signatures + trigger-enforced
append-only. The only seam: periodic Merkle-root snapshots (
chain_snapshots) that a future cron job could anchor externally (OpenTimestamps). One column and one cron job, priced as an enterprise checkbox — never an architecture. - Pooled+RLS first, dedicated on first enterprise conversation, SQLite for development. Pooled→dedicated is a copy; dedicated→pooled is a schema redesign under load — so pooled is built first.
- Shared compute only until a contract pays for namespace isolation or contract-specific BYOC.
- Proxy owns the turn (two-pass state machine), not a passive filter.
- The gate is deterministic code; model output is parsed, never trusted.
- Verification matching is keyword-dumb in v0.1 — an auditable wrong match beats an unexplainable embedding match in a product whose pitch is provenance.
- Stack: TypeScript pnpm monorepo, Node 20+, Next.js + Tailwind (design tokens ported
from
docs/design/*.html), docker-compose fixtures. See CLAUDE.md. - Numbers are earned. Every metric on the design pages (87% catch rate, $29, −38%) is a
placeholder until the harness produces it. Placeholder numbers in UI are marked
MOCK.
9. Phase-one milestone (reached)¶
Historical
This was the phase-one target and it was met. The project is now past phase six
("scenario coverage", complete 2026-09-03): 16 scenarios, 6/16 holdout, 384 published
runs. Current status is in CLAUDE.md and .claude/decisions/0004-phase-six-complete.md.
GTR-001 (scenarios/SPEC.md) running as a real docker-compose trap — nginx
client_max_body_size in a conf.d include masking a body-parser limit — with one model
walked through the proxy, and the harness printing catch or lucky_pass. That single
line of output is the moment the thesis becomes data. Build order in CLAUDE.md; one bounded
objective per session.