Skip to content

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:

  1. 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.
  2. 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:

  1. 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).

  2. 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 stats a customer's server — so the cloud is, in a real sense, the control plane over a data plane the customer operates.

  3. 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.

  4. 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_id on 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:

  1. 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.
  2. 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.
  3. 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_choice required; schema in docs/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.
  4. Verify. Diff declared lemmas against the facts table. Provenance claims are audited (unearned measured/told downgraded to believed — 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 with measured provenance. Unverifiable load-bearing lemmas → surfaced to the user as one compact confirmation — Confirm / Correct / Waive — before any solution exists; the answer writes a told fact or an explicit waiver to the chain.
  5. 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.
  6. 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:

  1. Shared compute + pooled DB (free/individual)
  2. Shared compute + dedicated DB (business/enterprise)
  3. Dedicated K8s namespace with resource quotas (regulated buyers)
  4. 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.