> **Generated 2026-08-29T18:46:30Z** · docs build `f3ff88cc`. Authoritative copy: https://witbitz.chat/llms-full.txt — if a copy you fetched shows an older Generated timestamp than this, it is a cached/stale edge; re-fetch. # Witbitz > Witbitz (https://witbitz.chat) is a **trusted runtime** for building collaborative applications where humans, > applications, and autonomous AI agents work together safely, under explicit permissions. You build your app as a > **static website with a verifiable privacy policy**, and Witbitz provides the runtime underneath — identity, > permissions, agents, storage, payments, and secure execution. It is built on a proven, open real-time engine and > inherits its account-free, link-based room model. Witbitz is **three layers**: (1) **interaction** — how humans and AI meet, today live calls and async Spaces; (2) **the platform** — a general-purpose app backend that carries the whole app flow and the AI that runs *inside* the app; (3) **the app** — a static page with **no backend of its own** that connects only to Witbitz. To be precise this is *not* "no backend" — Witbitz **is** the backend; the app trades an arbitrary per-app server for one constrained, inspectable platform, which is what makes its privacy *verifiable* (one auditable trust surface, not zero). Everything happens in a **Space**: a self-authenticated room whose identity, members, and keys live in the link, not a central account server. The platform is **content-blind** — a Space's data is sealed to a key the platform never holds, so as long as the compute runs the declared program, neither the operator **nor the app's own owner** can read the users' content (the "double blind"). Agents never own authority; they receive delegated, revocable authority, and a human keeps the final say (propose → approve → execute). The first app is **Spaces**, a multiplayer ChatGPT. This file is a map for language models. Each page below links to its clean Markdown (`.md`). The full text of every doc inlined into one file is at https://witbitz.chat/llms-full.txt. The **authoritative, machine-readable status** of each capability (`Production | Beta | Implemented | Design`) is a small JSON file at https://witbitz.chat/docs/capabilities.json — the single source of truth the docs are built against. If a status claim in prose ever looks ambiguous or contradictory, trust this file: the build refuses to publish a corpus that disagrees with it. ## Start here - [Overview](https://witbitz.chat/docs/overview.md): the fastest orientation — Spaces, static apps, the runtime underneath, the two guarantees, and where to go next. Read this first. - [Status](https://witbitz.chat/docs/status.md): what is live, private beta, built-but-not-production, and designed. Read this before making security, product, enterprise, or roadmap claims. - [What Witbitz is (portal home)](https://witbitz.chat/docs/): the trusted-runtime idea, the three layers, the double blind, the featured Spaces app, and what you can build (the app topologies). ## Live demos (see the runtime work, then read how) - [Lumen — the app-platform demo](https://witbitz.chat/lumen.md): a plain static HTML file that is secretly a live, shared, persistent, private AI app from a single ` ``` No link yet? Mint a room **inline** from a config — no pre-creation, no key handling on your page: ```html
``` That last block is the pattern: **a function on your page becomes a tool the agent can call.** `Witbitz.embed()` also returns `signIn`, `ask`, `invoke`, `setTheme`, `setState`, and `destroy` — see [embed](./embed.md). **Configure the agent** — the same **sealed** config either way (the server never sees it in the clear): | Field | Meaning | |---|---| | `agentName` | Display name (≤ 80 chars) | | `persona` | System instructions (≤ 4000 chars) | | `tools` | Platform tools the agent may use — `search_places`, `show_places`, `search_flights`, … ([tools & widgets](./tools-widgets.md)) | | `immediateTools` | The subset that auto-runs; everything else becomes a **human-approved proposal** ([delegated authority](./trust-model.md#delegated-authority)) | | `model` | Optional model override | **How the tenant and the room fit.** The embedded Space runs on the **Witbitz origin** and authenticates by **key-possession** — the room key rides the link fragment, never your page and never your tenant key. Your **tenant** is your identity on the **platform** (`/v1`): it meters usage to you and unlocks the platform surface (collections, sessions, agents). So the room is private by key-possession; the tenant is how the platform knows the app is yours. --- ## Next - [**Deploy it**](./deploy.md) — host your page on your own branded origin. - [**Verify it**](./verify.md) — confirm the deployed Space is exactly what it claims. - **Reference:** [Platform API (OpenAPI)](./api-reference.md) · [The SDK](./sdk.md). --- # Deploy it Your Spaces app is a **static page** — host it anywhere, on your own branded origin. The verified Space runs on the Witbitz origin inside the embedded iframe, so the room key and plaintext stay off your page. > **Get Started path:** [Vault Spaces](./vault-spaces.md) → this → [**Verify it**](./verify.md). --- ## Host the page Host your HTML on **Cloudflare Pages, Netlify, S3, or your own server** — no build step, no backend. Two CSP notes for your page: - allow **`script-src`** for the origin you load from (`https://witbitz.chat` for `embed.js`, or the SDK origin); - for the **headless SDK**, allow **`connect-src`** for your Space `endpoint` (default `https://api.witbitz.chat/v1/space`). That's it — the Space itself is served, verified, from the Witbitz origin inside the iframe. ## If you host the Spaces app itself If you host the *Spaces app* (rather than embedding the hosted one), embedding is **off by default** (`frame-ancestors 'self'`); set `SPACE_EMBED_ORIGINS` to the origins allowed to frame it ([embed](./embed.md)). ## Production notes for your tenant You registered a tenant in [Vault Spaces](./vault-spaces.md) — that meters usage to you and is your identity on the `/v1` platform. When you go past the walkthrough: - **Keep a demo tenant.** A demo tenant is real and works immediately, but auto-purges at its `expires_at`. To make it permanent — **same id and keys**, no more expiry — upgrade it with an author coupon: ```bash curl -sX POST https://api.witbitz.chat/v1/tenant/upgrade \ -H "Authorization: Bearer wsk_…" -H "X-Author-Coupon: " ``` Or sign up non-expiring from the start — `POST /v1/tenants/signup` (`X-Author-Coupon`). No coupon? Email **hello@witbitz.chat**. - **Owner-governed rooms** — mandate admission tiers that are provably yours across every room your app creates: built, built and tested, not yet enabled in production ([owner rule](./identity-and-admission.md#the-owner-rule)). - **A bespoke Witbitz-hosted app** — instead of hosting your own page, Witbitz provisions your tenant its **own deployment**: its own origin (`witbitz-spaces-.pages.dev`), its own key vault (a separate sign-in origin that confines the room keys — the same architecture the first-party Spaces runs on), its own split-key ecosystem key, and your agent config baked in. Sign-in needs **zero setup on your side** — Google and email-code run through the central Witbitz auth service, minted as your origin. Your app version is **yours**: it doesn't silently change when the reference Spaces updates. Rooms are stamped to your tenant and split-key by default. Provisioned per tenant — email **hello@witbitz.chat** with your tenant id and app name. - The billed `/v1` surface is invite-gated private beta; the **demo tenant** and the keyless `/space` runtime work openly ([Platform API](./api-reference.md)). ## Next - [**Verify it**](./verify.md) — confirm the deployed Space is exactly what it claims. - **Reference:** [Platform API (OpenAPI)](./api-reference.md) · [The SDK](./sdk.md). --- # Verify It Yourself Witbitz's claim is **verifiable, not promised** — so this page is where you *check* it, in your own terminal, instead of taking our word. It's the honest counterpart to every "enforcement-proven" line in the other docs: here are the exact commands, and here — just as plainly — is what they do **not** prove yet. > See also: [The Verified Room](./identity-and-admission.md) (the model you're checking) · [the Space link is the auth](./identity-and-admission.md) (how a real member passes) · [the double blind](./trust-model.md#the-double-blind) (the privacy claim + its north-star). --- ## What this page proves — and what it doesn't Be exact about the scope, because "verify it yourself" is itself a claim that can overreach: | | Status | |---|---| | **The admission gate enforces** — reads *and* writes are refused without an allow-listed identity, even when you hold the room key | ✅ **checkable now** (Tests 1–3) | | **The app's egress is locked** — the app you loaded can reach only a published allowlist of hosts; the browser blocks everything else | ✅ **checkable now** (Test 4) | | The store holds only ciphertext, **sealed to a key the platform never holds** (it can't read what it stores) | ✅ **checkable now** (Test 5) | | The deployed render is a **faithful build of published source** — rebuild it and get the same hash AWS runs | ✅ **checkable now** (Tests 6–7) | | The render is unobservable **in use** (operator can't read the plaintext during the decrypt instant) | 🔶 **Verifiable once the enclave is on** — the tier itself is **Production**; turn it on and this becomes checkable on your own traffic ([the attested tier](./the-attested-tier.md)); enabling it is the app builder's per-room choice | So today you can verify **that the gate enforces** — the load-bearing "who may read/write" layer — **and** that the front-end's data footprint is locked to a published allowlist (Test 4). You can **also** verify the store holds only ciphertext sealed to a key the platform never holds (Test 5) and that the deployed render is a **faithful, reproducible build of published source** (Tests 6–7). The one thing you **cannot** yet verify **about your own traffic** is that the render is **unobservable in use** — that during the transient decrypt-and-render instant the operator can't read the plaintext out of memory; that needs the attested/TEE tier ([the double blind §4–6](./trust-model.md#the-double-blind)). That tier is now **in production**, and independently verifiable — the room key is generated inside AWS Nitro hardware, the image measurement is reproducible from published source, the whole check runs in your browser ([the attested tier](./the-attested-tier.md)) — turn on "Run this Space inside the enclave" and its key is sealed to a key born inside the measured image, verified in your browser against the running enclave's published measurement (`/enclave-claim.json`) with a fresh nonce. Enabling it is the app builder's per-room choice — the platform can run any room in the enclave — and the model call leaves the measured image only through an attested no-log gateway into confidential-compute inference on an open-weights model — the frontier-vendor residual applies only to rooms that choose the frontier trade. Honest position: **the code is exactly this — readable, reproducible, and what AWS runs — and "it can't be watched while it runs" is now buildable, checkable, and in production for any Space the app builder runs in the enclave.** --- ## Read the render — the code that touches your plaintext Verification is only worth as much as your ability to *read* what you're verifying. Plaintext opens in one place — the server-side render — and that surface is **small**: ~600 lines for the seal / turn / read core. It's published, readable, Apache-2.0: **→ [the published render source](https://github.com/kibitz-chat/witbitz-render)** | File | Lines | What it does | |---|---:|---| | `envelope.mjs` | 287 | seal/open — a per-write key wrapped **per recipient** (`room` under `HKDF(mk)`); **no** operator recipient | | `asyncTurn.mjs` | 228 | the decrypt-once turn: open ledger → append → model → append → re-seal → drop `mk` | | `spacePoll.mjs` | 79 | the read: the sealed blob never leaves the server; unchanged = no decrypt; changed = new entries as plaintext | | `sessionStore.mjs` | 243 | session memory, sealed with `mk`, fail-safe | That repo is a **readable mirror**; the hash-verified canonical is `/source.tar.gz` (Test 7), which rebuilds byte-for-byte to the deployed hash. Read the logic there; confirm the bytes here. --- ## Setup (30 seconds) 1. In the [Spaces app](https://witbitz-spaces.pages.dev), create a Space with **"Who can post?" → Only specific Google accounts**, and add any email to the allow-list. This is an `admission:'email'` Space. 2. From its link `…/space?room=sp-XXXX#mk=YYYY&gate=email`, copy the **`room`** (`sp-XXXX`) and the **`mk`** (`YYYY`) — the room key that *everyone with the link holds*. 3. Discover the API endpoint (it's public; the app fetches it the same way): ```bash SPACE=$(curl -s https://witbitz-spaces.pages.dev/space-config \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["spaceEndpoint"])') echo "$SPACE" # the /space Lambda URL the client posts to ``` You now hold exactly what a link-holder holds: the room and its key. The tests below show that **is not enough**. --- ## Test 1 — the read gate: the link alone reveals nothing A token-less `poll` — the read op — with a *valid* room key: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"poll","room":"","mk":"","sinceEtag":"","sinceCount":0}' # → {"error":"not_authorized","reason":"unsigned"} # 403 ``` Holding `mk` returns **no history**. Reads require an allow-listed Google sign-in — the server won't decrypt-and-return without one. ## Test 2 — the write gate: you can't post without an allowed identity A token-less `turn` — the write op: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"turn","room":"","mk":"","message":{"text":"hi"}}' # → {"error":"not_authorized","reason":"unsigned"} # 403 ``` Rejected before anything is written — nothing lands in the Space. ## Test 3 — positive control: the key is valid; the 403 is the *gate*, not a bad key Create a second Space with **"Who can post?" → Anyone with the link** (an `open` Space), and `poll` it the same way with **its** room/key: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"poll","room":"","mk":"","sinceEtag":"","sinceCount":0}' # → 200 (returns entries) ``` Same request shape, a valid key — but **no admission policy**, so it reads. This is the control that proves Tests 1–2 are the **admission gate** refusing you, not a malformed request or a wrong key. An open Space reads by the link; an email Space does not. ## Test 4 — the egress footprint: the app can only talk to a published allowlist The other three tests check the *server*. This one checks the *app itself*: a static page can `fetch()` anywhere, so "the app doesn't leak your data to third parties" is a real claim — and it's now **browser-enforced**, not promised. Read the enforced Content-Security-Policy straight off the response header — it is the *exhaustive* list of hosts the loaded app is permitted to reach: ```bash curl -sI https://witbitz-spaces.pages.dev/space | grep -i '^content-security-policy:' # default-src 'none'; script-src 'self' 'sha256-…' 'wasm-unsafe-eval' https://accounts.google.com; … # connect-src 'self' blob: https://api.witbitz.chat https://accounts.google.com; … frame-ancestors 'self' ``` Read it directly: - **`connect-src`** is the data path — where the app may send anything. It is exactly **`'self'`** (the same-origin app + its map/tile proxy), **`https://api.witbitz.chat`** (the Space backend), and **`https://accounts.google.com`** (Sign-in). No analytics host, no CDN, no third party. Your data has nowhere else to go — the browser refuses it. - **`script-src`** allows only same-origin files **and each inline block by its `sha256`** — **no `'unsafe-inline'`**. So even injected script can't run, and can't exfiltrate. - **`default-src 'none'`** means anything not named above is denied by default; `form-action` and `object-src` are shut; `frame-ancestors 'self'` blocks cross-origin framing. This is the **egress-allowlist half of the signed certificate** ([the double blind §4–6](./trust-model.md#the-double-blind)) — now shipped and enforced. You are reading the app's true data footprint off ground truth. (What it does **not** prove is that the render is *unobservable while it runs* — that's the attested tier, now live as a per-room option ([the attested tier](./the-attested-tier.md)). That the render *binary* is the published, reproducible build is proven separately by Tests 6–7. This test bounds the **front-end**; the gate tests bound **access**.) ## Test 5 — the store holds only ciphertext, sealed to a key we never hold The other tests check access and egress. This one checks *what the operator actually holds*. The `sealed` op returns the **exact bytes the server stores** for a room's chat — no key required, because reading ciphertext reveals nothing: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -d '{"op":"sealed","room":""}' | python3 -m json.tool # { # "alg": "AES-256-GCM · multi-recipient envelope v1", # "bytes": 14554, # "sha256": "ad7c09…", ← hash of the exact stored bytes # "recipients": ["room"], ← WHO can decrypt: only the room key. NOT the operator. # "sealed": "{\"v\":1,\"iv\":\"…\",\"ct\":\"…\",\"recipients\":{\"room\":{…}}}" ← the opaque envelope itself # } ``` Two things to read straight off it: - **`recipients` is `["room"]`.** The envelope is sealed to exactly one key — the room key (`mk`), which lives in your link's `#fragment` and *never reached the server* (you didn't send it in the request above). The operator would be a recipient **only if** `"beta"` or `"admin"` appeared in that list. It doesn't. So the operator holds no key that opens this blob — an `aws s3 cp`, a backup, or a subpoena returns exactly these bytes: noise. - **`sealed` is opaque.** It's an AES-256-GCM ciphertext; without `mk` it is indistinguishable from random. Confirm it's genuinely *your* chat, not a decoy, by decrypting it locally with the `mk` from your link — the same key the server never saw. This is **content-blindness, made checkable**: what the operator stores is ciphertext sealed to a key it does not have. (What it still can't prove: that the **render** — the one transient moment plaintext exists to run the model — doesn't secretly keep `mk`. That's declared-program-in-use, addressed next.) ## Test 6 — the signed build certificate: what the platform DEPLOYED, committed in public The last surface is the render code itself — where plaintext opens (to run the model on a turn, and to return the new entries on a changed read). Full proof (that the running binary is a faithful, un-tampered build) needs an attested tier (below). But the **first step** ships today: the platform publishes a **signed certificate** at `/cert.json` binding what it deployed to ground truth — a build identifier (git commit), the running Lambda's AWS `CodeSha256`, and the egress allowlist — and the running service reports the same identifier via `op:attest`. ```bash curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -m json.tool # { "signed": "{\"v\":1,\"service\":\"witbitz-space\",\"gitCommit\":\"…\",\"lambdaCodeSha256\":\"…\",\"egress\":\"…\"}", # "sig": "…", "alg": "ES256", "pubkey": {"kty":"EC","crv":"P-256","x":"…","y":"…"} } curl -sX POST "$SPACE" -H 'content-type: application/json' -d '{"op":"attest"}' # → { "service":"witbitz-space", "gitCommit":"", "runtime":"aws-lambda" } ``` Verify the signature yourself — the platform's **public key** is published here, so compare it to the `pubkey` in the cert and check the signature over the exact `signed` string (ES256 / ECDSA P-256): ``` {"kty":"EC","crv":"P-256","x":"TFXrR5DFL3Oo3rBwa6clwJnrfvw0TN2AewDDEcy8hBM","y":"MgLLzEq7g8B86qS7NNxkIW64ytp296d1kkK1wSKBoec"} ``` ```js // node — verifies the cert's signature against the published key (WebCrypto) const c = await (await fetch('https://witbitz-spaces.pages.dev/cert.json')).json() const ub = (s) => Uint8Array.from(Buffer.from(s.replace(/-/g,'+').replace(/_/g,'/'),'base64')) const k = await crypto.subtle.importKey('jwk', { ...c.pubkey, ext:true, key_ops:['verify'] }, { name:'ECDSA', namedCurve:'P-256' }, false, ['verify']) console.log(await crypto.subtle.verify({ name:'ECDSA', hash:'SHA-256' }, k, ub(c.sig), new TextEncoder().encode(c.signed))) // true ``` **What it proves:** the platform has publicly, cryptographically **committed** to a specific deployed build (a named build id + the Lambda's exact code hash) with a specific egress. The commitment is tamper-evident (a changed cert fails the signature), and the running service reports the same build id — so the operator can't quietly point the certificate at one build while running another without it showing. On its own, Test 6 is a *tamper-evident commitment to a build*. **Test 7 turns it into a check of the actual code** — the cert also carries `sourceSha256`, the hash of the *published, readable source* that reproduces `lambdaCodeSha256`. ## Test 7 — the faithful build: rebuild the published source, get the deployed hash The cert commits to a code hash. Test 7 lets you confirm that hash is a **faithful build of source you can read** — not a black box. The exact render source is published at `/source.tar.gz`; the cert's `sourceSha256` pins it, and it rebuilds **byte-for-byte** to `lambdaCodeSha256`. Needs only **Node + Python 3** — no Docker. The zip is **STORED** (uncompressed), so the bytes don't depend on a zlib version; `npm ci` is deterministic for this pure-JS graph (no native binaries). ```bash # 1) download the published render source and confirm it's the one the cert names curl -s -o source.tar.gz https://witbitz-spaces.pages.dev/source.tar.gz python3 -c 'import hashlib,base64;print(base64.b64encode(hashlib.sha256(open("source.tar.gz","rb").read()).digest()).decode())' curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -c 'import sys,json;print(json.loads(json.load(sys.stdin)["signed"])["sourceSha256"])' # ↑ these two must match — the source you downloaded IS the source the cert commits to. # 2) rebuild it and compare to the deployed hash tar xzf source.tar.gz && bash tools/reproduce-space-build.sh # → reproduced CodeSha256: curl -s https://witbitz-spaces.pages.dev/cert.json | python3 -c 'import sys,json;print(json.loads(json.load(sys.stdin)["signed"])["lambdaCodeSha256"])' # ↑ these two must match — you just rebuilt the exact bytes AWS is running (its CodeSha256). ``` Both match ⇒ **the deployed render is a faithful build of code you just read.** No trust in the build server: you reproduced it. **Honest scope — the one thing left.** This proves the deployed *code* is a faithful build of *readable* source. It does **not** prove the running *process* is unobservable **in use** — that during the transient decrypt-and-render instant, the operator (or the cloud) can't read the plaintext out of memory. That is the **attested/TEE tier** (a confidential-compute enclave whose remote attestation binds the running measurement to this reproducible build). **Production, and independently verifiable**: see [the attested tier](./the-attested-tier.md). Everything up to and including *"the code is exactly this, and it's readable and reproducible"* is checkable today; *"and it can't be watched while it runs"* is now in production for any Space the app builder runs in the enclave — its per-room choice. --- ## Test 8 — drive the real browser: prove the locks are *enforced* (the CDP verifier) Tests 1–7 read *a response* — a header, a stored blob, a cert. That proves what the server **declares**, but not what your **browser** actually enforces on the live app — and a passive fetch is **blind to a Service Worker**, which can serve tampered bytes from its own cache with no network request at all. To close that, *drive* a real browser and check the app's claims from the browser's **ground truth** — below the page, below any Service Worker: ```bash node tools/lumen-verifier.mjs # drives https://witbitz.chat/lumen (pass a URL to point elsewhere) ``` It proves, from the running app rather than from a fetch: - **the egress-lock is *enforced*** — it runs a forbidden `fetch()` *inside the room* and confirms the browser **blocks it** with a `connect-src` violation (not merely that the header *says* so); - **cross-origin isolation holds** — the embedding site gets a `SecurityError` trying to read the room's DOM ("can't read a word"); - **no rogue Service Worker** — it enumerates the **browser-owned** registration list, which a Service Worker cannot forge; the one thing a load-time or `curl` check can't see; - **byte identity** — the inline shell's SHA-256 is surfaced and confirmed to be one of the CSP hashes the browser pinned, so you can compare it to the reproducible source (Test 7). **Honest scope.** It verifies the *browser it drives* — so **run it yourself**, or it's just checking someone else's browser (the "whose fetch" caveat, now at the browser level). It's a desktop tool; a stock **mobile** browser can't be driven this way — which is exactly what the **native attested client** ([the attested client](./client-architecture.md#the-attested-client)) is for: it *is* a controlled, bundled-local browser, so there's no rogue-worker surface and no external driver needed. --- ## Reading the result codes The gate is fail-closed; every rejection is a `403 not_authorized` with a precise `reason`: | `reason` | Meaning | |---|---| | `unsigned` | no identity presented at all (no `idToken` + `spk`) | | `token: <…>` | a Google token was presented but failed — e.g. `token: expired`, `token: bad_aud`, `token: bad_signature` (RS256-vs-JWKS) | | `nonce_mismatch` | a valid token, but its `nonce` isn't bound to the signing key it was presented with | | `not_on_list` | a valid, key-bound token, but the email isn't on the Space's allow-list | | `revoked` | a valid, allowed identity that has since been removed from the Space | | *(none)* → `200` | accepted | ## How a real member passes (the signed path) An allow-listed member's client sends, with each request, an **`idToken`** (a fresh Google ID token) and **`spk`** (its device signing public key). The server checks, in order: the token verifies **RS256 against Google's JWKS**; its `nonce` equals **`spkNonce(spk) = base64url(SHA-256(canonical-JWK(spk)))`** (so the token is bound to *this* device's key); and the email is on the **sealed allow-list**. Only then does it decrypt-and-return, or accept the write. Full mechanism: [the Space link is the auth §4](./identity-and-admission.md). This is the async analog of the live call binding an OIDC token to a live DTLS cert — "signed by the keyholder who authenticated as this account, now." --- ## What you can't check here yet — and why Being explicit, so this page doesn't become the overclaim it's meant to prevent: - **Unobservable-in-use — the attested/TEE tier.** The render decrypts with `mk` for a transient instant — on an AI turn and on every changed read, the one door plaintext opens. Tests 6–7 now prove the deployed *code* is a **faithful, reproducible build of readable source** — you can rebuild it and read it. The last thing they can't prove is that the running *process* can't be watched while it runs: that during that instant the operator (or the cloud) can't read the plaintext out of memory. That needs a **TEE** — a confidential-compute enclave whose remote attestation binds the running measurement to this reproducible build (and thereby excludes even the operator + AWS from the render). **Live on real AWS Nitro hardware as a per-Space option — launch-on-demand, scale-to-zero** ([the attested tier](./the-attested-tier.md)); enabling it is the app builder's per-room choice. On that tier the model call also closes the last plaintext reader, the model vendor: it leaves the measured image only through an attested no-log gateway into **confidential-compute inference on an open-weights model** (fail-closed if that gateway does not attest) — shipped, with the frontier model kept as a per-room choice; your enclave check transitively covers that gateway's attestation. This page grew **"verify the gate" → "footprint" → "store" → "the build."** The gate is live (Tests 1–3), the footprint is live (Test 4), the store is sealed to a key we never hold (Test 5), the signed build commitment is live (Test 6), and the **faithful, reproducible build of published source** is live (Test 7). Everything up to *"the code is exactly this — readable, reproducible, and what AWS runs"* is now checkable. What remains is only *"and it can't be watched while it runs"* — the attested/TEE tier, **now live as a per-room option**: turn it on and verify your Space against the running enclave's published measurement (above). --- # The Concierge — a real app, end to end You've built a tenant, deployed a page, and verified the trust claims. This is the capstone: a **complete reference app** that puts it all together — a private evening **Concierge** that knows a great deal about its user, discloses only the **minimum** to an outside commercial service, and still works with it. > **Get Started path:** [Vault Spaces](./vault-spaces.md) → [Deploy it](./deploy.md) → [Verify it](./verify.md) → **this**. --- ## What you're building One neutral page embeds two isolated iframes: - **Your private Space** — the branded Concierge, on *your* tenant. It knows the user (their city, their taste, their budget, who they're going out with) — all of it sealed in the vault, none of it on the page. - **A commercial event finder** — an ordinary third-party app with its *own* backend and its *own* data key. It knows nothing about the user. The agent calls one host tool, `search_events`. **Only a minimal, neutral query crosses** the boundary — `city`, `date`, a broad category, an attendee *count* — never a name, never the conversation. The commercial side returns real events; the agent gets them back **in the same turn** and recommends the one that fits the private context. That is **capability-mediated disclosure**: the app that holds the data discloses a capability, not the data. The seam is a single origin-checked `postMessage` — see [Embed a Space](./embed.md) and [Tools & widgets](./tools-widgets.md). The app is otherwise ready to run. Two pieces of config — one on each side of that boundary — bring it up. ## 1. Get a key from SeatGeek (the commercial side) The event finder is a normal app with its own data source. Get a free **SeatGeek Platform** client: 1. Sign up at [seatgeek.com/account/develop](https://seatgeek.com/account/develop) and create an app. 2. Copy the **Client ID** — the short `NjA2…` string. **Not** the 64-hex *client secret*: that's for OAuth flows this app doesn't use, and SeatGeek rejects it with `403 Invalid client credentials`. 3. Set it as a secret on the commercial app — `SEATGEEK_CLIENT_ID` (a Cloudflare Pages secret, or an env var wherever that page's backend runs) — and redeploy so it's picked up. This key lives entirely on the commercial side — **Witbitz never sees it**, which is the whole point: the outside service keeps its own credentials, the private Space keeps its own, and neither reaches across. Without a key the reference adapter falls back to deterministic mock events, so you can watch the flow before you wire real data. > One SeatGeek quirk worth knowing: its free-text `q` is a literal title match, so a genre word like `jazz` returns almost nothing. Pass genres as the **category** (the reference adapter maps them to SeatGeek's `type` param) and let the agent pick the fitting act out of the real list it gets back. ## 2. Add your origin to your tenant Your Concierge creates **tenant-attributed** Spaces, and the data plane is **fail-closed**: a tenant admits a member's browser **only** from an origin it has declared. Until you add yours, every read (`poll`, `state`, `turn`) is refused — the room stamps to your tenant, but nothing loads. This is the participation gate ([Identity & admission](./identity-and-admission.md#the-owner-rule)); confidentiality is still the room key, always. Declare the origin the Concierge is served from, authenticated with your tenant secret key: ``` PUT /v1/tenant/webhooks Authorization: Bearer { "origins": ["https://your-concierge.example.com"] } ``` Use the **exact** scheme + host the Concierge iframe loads from — that's the `Origin` the platform checks — and add **every** origin you serve it from (a preview alias and production are two distinct origins). You can also pass `origins` when you first register the tenant. See the [Platform API](./api-reference.md) for the full `/v1/tenant` surface. ## That's it Load the page. The Concierge auto-creates its Space **on your tenant** — every room now carries your `tenantId`, so the rooms it makes are inventoried and reclaimable, not anonymous ([Vault Spaces](./vault-spaces.md)). Ask it to find something to do, and watch the neutral query — and *only* that — cross to the event finder, real events come back, and the agent recommends from your side of the wall: the one recommendation the event service could never make, because it never learned who the user is. ## Next - **Reference:** [Embed a Space](./embed.md) · [Tools & widgets](./tools-widgets.md) · [The SDK](./sdk.md) · [Platform API](./api-reference.md). - **Check it, too:** [Verify it yourself](./verify.md) — the same admission gate and egress lock cover this app. > The Concierge now also runs as an **app-faced Space** — the Event Finder full-screen with the room as a pull-up card, including per-room resume and the sign-in patterns: see [App-faced Spaces](./app-spaces.md). --- # App-faced Spaces A Space can **wear an app**: the full-screen surface is your app site, and the private room rides over it as a pull-up card — the input line sits exactly where a normal Space's does. The agent's tools execute against the app pane over an origin-pinned bridge, and the card's grab bar shows **exactly the structured fields that crossed** to your site — nothing else ever does. The reference is the Event Finder concierge: a Space whose "chat history area" is a live commercial events site. > **Status:** the app-site mode and the wire contract below are **live** (the Event Finder demo runs on them). > The sign-in patterns in §3 are **documented designs** — the recommended shapes, not yet shipped primitives. ## 1. Declaring an app site A branded deployment declares its app in the brand config: ```json { "agentName": "Concierge", "persona": "…", "appSite": "https://your-app.example", "tools": [ … ] } ``` Opening a Space on that origin renders `appSite` full-screen with the room as the card. Every Space on the origin gets the app face, the brand's tools, and per-room **resume**: the last search is stored in the room's own sealed state doc, so returning to a room (any device, any member) replays it. Three gates for a new app-site origin, all fail-closed: 1. the Spaces app's CSP must allow framing it (`SPACE_VAULT_APP_ORIGINS` at deploy); 2. the app site's `frame-ancestors` must list the **whole ancestor chain** (the Spaces origin, and the vault origin if the app runs vaulted — browsers check every ancestor); 3. the app site should **origin-pin inbound messages** to the Spaces origin(s). ## 2. The wire contract The pane speaks two messages down, one up — all `postMessage`, origin-pinned both ways: | direction | message | meaning | |---|---|---| | Space → pane | `{ type: "search", args }` | run a query; `args` are exactly the disclosure-strip fields | | Space → pane | `{ type: "focus", index }` | highlight the Nth already-shown result | | pane → Space | `{ type: "results", events: [...] }` | the public results — returned **into the agent's turn**, so it recommends from live data | The pane sees **only** these messages. It never receives the conversation, names, or reasons — the same-origin policy walls it off, and the bridge carries nothing but the structured arguments the strip displays. ## 3. Signing into the app pane Three patterns, in ascending capability. A key browser fact shapes all of them: the pane is an iframe, so it **cannot see your normal first-party login cookies** (third-party cookies are blocked or partitioned), and most authenticated sites refuse framing entirely — an arbitrary site can't be pointed at and inherit your session. A **cooperating** app site, however, has clean options. ### 3a. No sign-in (public data) — *what the demo ships* The pane serves public data keyed by nothing. Simplest, fully anonymous toward the site: it learns only the strip's fields per query. Right default for discovery-type apps. ### 3b. Personal account (per member) The standard embedded-app pattern (the same shape Spaces' own central auth broker uses): 1. the pane opens its **login in a popup** — top-level, so cookies are first-party and Google/password/passkeys work normally; 2. the popup hands the session back to the pane, which keeps it in **partitioned storage** (a `Partitioned` cookie or a token in the iframe's storage). Partitioned storage is stable per embedding origin, so you sign in **once per device** and stay signed in across rooms and reloads. What it buys: account prices, saved payment, a "buy" that completes. **Disclosure honesty:** the site can now join your queries to your identity — the strip should say so (`shared → … · as you@example.com`). Each member is signed into *their own* account, so panes may show member-specific views, and tool results come from whoever asked. ### 3c. Shared room account (a "party" account) — *the room-native pattern* If all members use **one stateful account**, the site's own backend becomes the room's shared surface: one cart, one favorites list, one booking — every member's pane converges by definition, with no extra sync protocol. The elegant part is **how the account is shared**: never a password. The site issues a **scoped session token** (a party token), and the token rides **sealed inside the room** — in the same encrypted per-room state doc that already carries the resume search: - **Invite to the room = access to the account.** A new member's pane receives the token over the bridge and is signed in with zero ceremony. The room key already gates who's in; the site token inherits exactly that boundary. - **The platform never sees it** — the token lives in the mk-sealed doc, end-to-end encrypted like the chat. - **Revocation = rotate the token** when membership changes (the same epoch model a membership change already implies). - The site sees one identity doing minimal structured queries; the strip shows `as `. Caveats: one identity for N people (right for a family or a trip crew, wrong where per-person accountability matters); concurrent edits are the site's job (carts handle this well); token rotation must accompany member removal, or a removed member's pane keeps working until expiry. ### Choosing | | site learns | shared state | best for | |---|---|---|---| | **3a** public | queries only | none (per-pane) | discovery, demos | | **3b** personal | queries + who you are | per member | purchases, personal accounts | | **3c** party token | queries + one shared identity | **the site's own state, room-wide** | planning together: shared carts, group bookings | ## Next - [The Concierge — a real app, end to end](./concierge-app.md) — the reference app-faced Space. - [Embed](./embed.md) — the host-page bridge this contract descends from. - [Trust model](./trust-model.md) — where the disclosure boundary sits. --- # Platform API — the `/v1` surface The tenant-keyed HTTP API a third-party app builds on. JSON in, JSON out, over HTTPS. This page is the map; the **machine-readable spec is canonical** — point tooling at it: - Production spec (only ops callable today): `https://api.witbitz.chat/v1/openapi.json` (`.yaml`) - Full design spec (every op, `x-status`-labelled): `https://api.witbitz.chat/v1/openapi.full.json` - Discovery index: `https://api.witbitz.chat/v1/` --- ## Base + envelope - **Base URL:** `https://api.witbitz.chat/v1` - **Success** is enveloped `{ "data": , "meta"?: {...} }`; **errors** are `{ "error": { "code", "message", "request_id"? } }`. One exception: `POST /space` (`x-envelope: false`) returns a raw payload and a flat error shape — unifying it is a tracked follow-up. - Version `1.0.0-beta`. Private beta — the contract may change (coordinated with invited integrators) until GA. ## Authentication Two tenant API keys, resolved by the `/v1` authorizer to a `tenantId`: - **Secret key** `wsk_` — full scope, **server-side only**. - **Publishable key** `wpk_` — browser-safe, public reads + funnel-entry writes. It *identifies* a tenant; it does **not** authenticate an end user. - **Per-collection capability tokens** (`x-collection-token`): the owner `admin_token` or the add-only `contributor_token`, for owner/contributor actions on a collection. Wallet/license credentials travel in the request **body**, never a URL. Capability tokens + raw keys are shown **once** at creation and stored only as a hash (or KMS-sealed). Store them server-side; lost = re-create. ## Operational contract - **Idempotency:** money/billable ops (`POST /sessions`, `/wallets/grant`, `/collections/{id}/paint`) require an `Idempotency-Key`; same key + same body replays the first 2xx byte-for-byte. - **Rate limiting + kill switch:** throttled at the gateway; per-tenant/per-key-type limits at the authorizer; an operator can suspend a tenant (`POST /tenants/{tenant_id}/status`) — every key is denied within ~15s. That op and `POST /tenants` are **operator-only** (`X-Admin-Token`, not a tenant key) and are deliberately absent from the published spec, so don't go looking for them in `openapi.json`. - **Secret handling:** one-time-secret responses are `Cache-Control: no-store`; sensitive fields `x-sensitive`; BYOK provider keys + the webhook secret are KMS-sealed. ## The surface (by area) **Agents** - `GET /catalog` — the public agent catalog (publishable). **Collections** — the generic content primitive (opaque metadata + items + curation + a keyless render page) - `POST /collections` · `GET /collections/{id}` · `DELETE /collections/{id}` - `GET /collections/{id}/render` — **keyless** share page for published content (public) - items: `GET/POST /collections/{id}/items` · `PATCH/DELETE /collections/{id}/items/{itemId}` - `POST /collections/{id}/state` — merge opaque app state onto the collection **Artifacts** — produced media on a collection - `GET/POST /collections/{id}/artifacts` · `GET/PATCH/DELETE .../artifacts/{artifactId}` - `POST /collections/{id}/paint` · `POST /collections/{id}/produce` - `POST /collections/{id}/artifacts/upload` → presigned S3 PUT, then `POST /collections/{id}/artifacts/{artifactId}/ready` to stamp it complete — both halves are required **Sessions** - `POST /sessions` — launch (or re-summon) an agent; metered, idempotent. Accepts **either** key: the spend credential is the coupon in the body, so a publishable key launches nothing without a funded wallet. **Credits** - `POST /wallets/lookup` · `POST /wallets/grant` · `POST /checkout` · `GET /usage` - `POST /wallets/redeem` — **two presentations**: a Lemon Squeezy `license_key`, or a blinded issuer `{token, sig, epoch}`. Both mint a `wallet_code` shown once. For a token the **token IS the idempotency key** — a retry gets `409 already_spent`, never a second wallet, so don't send an `Idempotency-Key`. - `POST /epochs` — **issuer role required.** Publish a batch's PUBLIC key and per-token value, after which tokens signed by the matching private key are redeemable. The platform holds only public keys, so it can verify what an issuer signed and can never mint on its behalf. Set once: rotation is a *new* epoch. **Roles.** A tenant is an `app` (builds a product, spends credit) or an `issuer` (holds the customer and the payment relationship, funds wallets from its own prefunded balance) — or both. `grant` and `/epochs` are issuer-only. A tenant with no `roles` recorded predates the split and is treated as holding both. Roles are set by an operator, not self-service. **Tenants** (onboarding + key management) - `POST /tenants/signup` · `POST /tenants` · `POST /tenants/{tenant_id}/status` - `GET /tenant` · `GET/POST /tenant/keys` · `DELETE /tenant/keys/{keyId}` - `PUT /tenant/provider-keys` (BYOK, KMS-sealed) · `PUT /tenant/webhooks` **Spaces** — `POST /space`, the async Space's single op-dispatched endpoint. It is in `/v1` but **keyless**: it authenticates by key possession, not a tenant key (see the note below). **Infra** — `GET /turn` (ephemeral TURN credentials). > **Not at `/v1`:** ratings, `/agents/*`, `/earnings` and `/sessions/{id}` are `x-status: legacy` — they exist on > the older surface but are *not* routed here, and calling them returns 404. They appear in `openapi.full.json` > labelled as such, never in the production spec. ## Access Invite-only during private beta. Email **hello@witbitz.chat** for keys. Everything above is served openly as a contract; the *keys* are what's gated. ## Notes - The async **Spaces** runtime (`/space`) is a separate, content-blind surface (key possession, not tenant keys) — see [async Spaces](./async-spaces.md) and [the Space link is the auth](./identity-and-admission.md). - This page is a summary; the OpenAPI at `/v1/openapi.json` is the source of truth (schemas, per-op auth, examples). --- # The SDK — a Witbitz app in an afternoon Witbitz is a runtime: a shared room with humans, an AI agent, sealed persistent state, identity and authority. The SDK is the small, stable surface over it. The trust machinery — the room key that never reaches the server, content-blind reads, the op-dispatch, the poll loop — disappears, so a **static page** can stand up a private, agent-backed, link-shared room in a few lines. > **Status: public beta.** Usable today from any static page; the surface below is small on purpose and meant to stay > stable. It wraps the exact reference client the production Spaces app runs on — nothing is forked or reimplemented. ## The whole thing ```html ``` That is a complete, multiplayer, private AI application. No backend of your own, no key handling, no database. ## The surface Three calls. That is the whole API. | | | |---|---| | `new Witbitz({ endpoint?, viewer?, app? })` | A client bound to the Space endpoint. Make one, reuse it. Defaults to the public production endpoint. | | `await wb.createSpace({ agent, tools?, immediateTools?, model? })` | Mint a Space and return a live `Space`. `agent` = `{ name, instructions, tools?, model? }`. | | `await wb.openSpace(link)` | Join a Space from a share link (or the current page URL, if it is one). The link's fragment carries the key; it never touches the server. | A `Space`: | | | |---|---| | `space.shareLink` | The link to hand someone — the link is the admission capability. | | `space.room` | The server-visible room id. | | `space.on('message', cb)` | `cb({ from, text, self, id, ts, widgets? })` for every entry — yours, other members', and the agent's (`self: true`). On an opened Space, the existing history replays here first. | | `await space.send(text, { from? })` | Post a message. The agent decides whether to answer and does so on its **own** turn; the reply arrives via `on('message')`, not as a return value. | | `await space.sealedProof()` | What the server *actually* stores for this room — the opaque, sealed ledger, plus its size, hash, and who can decrypt it (the operator never can). Lets your app **prove** the operator can't read the store, not just claim it. `null` before the first message. | | `await space.privateLane({ agent, actions? })` | Attach a **private assistant lane** for the current member — see [Private lanes and human-approved actions](#private-lanes-and-human-approved-actions). → a `Lane`. | | `space.on('error', cb)` | Transient poll errors (non-fatal; the loop keeps going). | | `space.close()` | Stop streaming and release the handle. | ## What you get, and what's handled for you Standing up a Space gives your app, for free: - **Privacy that's verifiable, not promised.** State is sealed to a room key the SDK derives on the client; the server stores only its commitment and **never retains the key**. The SDK reads via `pollsealed`, so **`mk` stays on the device for reads** — it opens the sealed ledger locally. An agent **turn** is the one exception: it hands `mk` to the server-side render for the transient **decrypt-once** that runs the turn, then the render drops it — that single instant is what the [attested tier](./the-attested-tier.md) closes. See [Async Spaces](./async-spaces.md), [Trust model](./trust-model.md), and [Verify it yourself](./verify.md). - **An agent that participates, not one that rules.** The agent answers turns and can use tools, but high-stakes actions become proposals a human approves — it never holds the credential. See [Delegated authority](./trust-model.md#delegated-authority). - **Admission in the link.** The share link is a self-contained capability; there is no account server to sign up against. For allow-listed rooms, add an email gate (see below). See [The Space link is the auth](./identity-and-admission.md). - **Idle-cheap persistence.** A quiet Space is sealed bytes on disk, not running compute; the agent is a function invoked on a turn, not a daemon. See [Async Spaces](./async-spaces.md). You never touch: the room key, envelope sealing, the `/space` op-dispatch, the poll/etag loop, or endpoint resolution. ## Tools and human authority Give the agent platform tools by name; the ones in `immediateTools` it may call directly, the rest become proposals a member approves before they run. ```js const space = await wb.createSpace({ agent: { name: 'Travel', instructions: 'Plan trips; put options on the shared map and list.' }, tools: ['search_places', 'show_places', 'add_place', 'search_flights', 'show_flights', 'set_itinerary'], immediateTools: ['search_places', 'show_places', 'set_itinerary'] // read/show freely; anything effectful is a proposal }) ``` The full catalogue (places, flights, itinerary, chart, photo, `read_file`, `read_url`, `write_pdf`) is in [Tools and widgets](./tools-widgets.md). ## Private lanes and human-approved actions Attach a **private assistant lane** to a shared Space with `space.privateLane({ agent, actions })`. The lane is the member's own room: its agent sees the shared room's messages and thinks/drafts with the member privately, but can post into the shared room only through a crossing the member approves. Give it `actions`, and it can only ever **propose** them — nothing runs until a human approves, and the action then executes on the member's device from your own handler. The agent never holds the credential. ```js const shared = await wb.createSpace({ agent: { name: 'Team' } }) const lane = await shared.privateLane({ agent: { name: 'Ops assistant', instructions: 'Help me run the store. Propose refunds and emails; I approve.' }, actions: [ { name: 'issue_refund', description: 'Refund a customer', input: { type: 'object', properties: { customer: { type: 'string' }, amount: { type: 'number' } }, required: ['customer', 'amount'] }, run: (a) => myBackend.refund(a.customer, a.amount) }, // ← runs ONLY after the member approves ], }) lane.on('message', renderPrivateThread) lane.on('proposal', (p) => showApproveDeny(p)) // { id, kind:'message'|'action', action, args, text, approve(), deny() } lane.on('resolved', ({ approved, action }) => { /* … */ }) await lane.send('Refund Jane $40 for the late order, and email her an apology') ``` A `Lane` **is** a `Space` (its private thread streams on `message`), plus: | | | |---|---| | `await lane.send(text)` | Talk to your assistant privately. Anything it wants the shared room to see, or any effectful action, it must **propose**. | | `lane.on('proposal', cb)` | `cb({ id, kind, action, args, text, approve(), deny() })`. `kind:'message'` is a drafted crossing to the shared room; `kind:'action'` is one of your `actions`. **Nothing happens** until `approve()` (posts the crossing / runs your `run` handler on the device) or `deny()`. | | `lane.on('resolved', cb)` | `cb({ id, approved, action, result? })` after a decision. | Effectful `actions` execute **on the member's device** from your `run()` handler — the runtime records the approval but never runs them itself, so the credential and the side-effect stay on your side. This is the delegated-authority model ([Delegated authority](./trust-model.md#delegated-authority)), one method call away. It's the piece that's genuinely hard to build safely by hand: a propose→approve→execute state machine, an audit trail, and a hard guarantee the agent can't act unilaterally. ## Configuration - **`endpoint`** — the Space API. Defaults to the public production endpoint (`https://api.witbitz.chat/v1/space`), a self-authenticating endpoint: a Space proves itself by its key-commitment, not a tenant account. - **`viewer`** — the origin a `shareLink` opens in. Defaults to the reference Spaces app, so a link Just Works. Point it at your own origin to open Spaces in a viewer you build (the SDK also exposes `space.key` so you can construct links yourself). - **`app`** — an optional tag recorded with the Space. - **On-prem / your own model, keys, and IdP:** point `endpoint` at your deployment. See [Deployment options](./deployment-options.md) and [On-prem](./deployment-options.md#your-boundary). ## Try it A complete example app (create-or-join a shared assistant room, ~40 lines) is live at [witbitz-spaces.pages.dev/sdk-example.html](https://witbitz-spaces.pages.dev/sdk-example.html) — open it, then open the share link in a second tab to see the same sealed room from another "member." Its source is `sdk-example.js` on the same origin. A second example shows what the SDK uniquely makes easy — **[The Confidential Room](https://witbitz-spaces.pages.dev/confidential-example.html)**: talk to an AI advisor about something you'd never paste into an ordinary chatbot, while a live panel fetches `space.sealedProof()` and shows the server holds only an **opaque envelope**, sealed under a key it never receives. Building this safely without Witbitz means end-to-end encryption, a key model where the server can't hold the key, and a way for a user to *audit* it — here it's the runtime, and the proof is one method call. A normal AI product asks you to *trust* it won't read your data; this one lets a user **check**. And **[The Ops Desk](https://witbitz-spaces.pages.dev/approvals-example.html)** shows human-in-the-loop authority: a private assistant that can *draft* real actions — refunds, emails — but can never run one on its own. Each is a `proposal` you approve, and only then does it execute, on your device, from your handler. The agent never holds the credential. It's a `privateLane({ actions })` plus `on('proposal')` + `approve()`. ## Notes and limits - The SDK is a single ES module; cross-origin `import` from the URL above pulls its dependencies from that origin automatically, so your page's CSP must allow it (`script-src` and `connect-src` for your `endpoint`). - The reference `viewer` is the fastest way to see a Space; a production app typically builds its own UI with `on('message')` + `send`, exactly like the example. - Email-gated admission, private per-member lanes, and running a Space inside the attested enclave are supported by the runtime; the thin surface above covers the open-room path first. See [Identity and admission](./identity-and-admission.md) and [The attested tier](./the-attested-tier.md). Read next: [Vault Spaces](./vault-spaces.md) to build with the reference Spaces, and [Platform](./platform.md) for the runtime this rides on. --- # Embed a Space — one line, and the drop-in doesn't cost you the encryption > **Status: live.** The host-side SDK is served at `https://witbitz.chat/embed.js`; the iframe seam ships in the Spaces > app. **Live demo → [witbitz.chat/demo](https://witbitz.chat/demo):** talk to an embedded AI and it calls the *page's own* > `set_site_color` tool to restyle the site — *your app's functions become the agent's tools*. A simpler read-only > event-log demo (the bridge events, no host tools) is at > [`/site/embed-demo.html`](https://witbitz.chat/site/embed-demo.html). Cross-origin, browser-verified. ## The one-liner ```html ``` or drive it from script: ```js const space = Witbitz.embed('#slot', { link }) space.on('message', m => …) // someone posted (metadata only) space.on('turn', t => …) // an agent turn finished space.signIn(token) // owner-gated rooms: admit the user at their tier ``` `Witbitz.embed(target, opts)` returns a controller: `on(type, cb)` / `off`, `signIn(token)`, `navigate(to)`, `setTheme(theme)`, `destroy()`, and `iframe`. `` re-exposes it as `el.space`. ## Why it's an iframe — and why that's the point That one line mounts a **cross-origin iframe on the Spaces origin**, not inline DOM in your page. That isn't a limitation to apologize for — it's what keeps the guarantees true when a Space is embedded: - The **verified** Space client runs in **its own origin**. Your page — and every tracker and third-party script on it — **cannot reach into it**, cannot read the room key, cannot read the plaintext. - If a Space were inlined into your page, your page would become part of its trusted computing base, and *content-blind* would quietly stop being true the moment anyone embedded it. The iframe is the isolation boundary. So a drop-in embed **does not cost you the encryption** — the part that touches your users' content still runs only in verified code, in a context your page can't see. ## The bridge (host ⇄ Space) The only channel across an origin boundary is `window.postMessage`. The SDK layers a small, **origin-checked** protocol (JSON-RPC 2.0) on it. It is a **narrow capability seam, not shared memory** — the host can do exactly these things: | Direction | Message | Meaning | |---|---|---| | Space → host | `ready` | the Space handshaked; the bridge is open | | Space → host | `message` | someone posted — **metadata only** (`{id, self, sys}`), never content | | Space → host | `turn` | an agent turn finished | | Space → host | `member` | a member event | | Space → host | `resize` | content height changed (auto-sizes the iframe) | | host → Space | `signIn` | hand in an owner IdP token / signed grant | | host → Space | `navigate` / `theme` | deep-link / theme within limits | Discipline that makes the seam safe: **origin-checked both directions** (the Space only accepts control from the one validated embedder; the host trusts only the Spaces origin), **nothing is posted to `'*'`**, and **the room key never crosses the bridge** — it rides the iframe's URL fragment, client-side, and is never sent to a server. Sign-in is a **verified token**, not a trust-me flag: it's checked against the room's sealed allow-list and resolved to a tier (see [the owner rule](./identity-and-admission.md#the-owner-rule)). One honesty note: the app that *embeds* a room necessarily holds that room's link, so it holds that room's key — the isolation doesn't hide the embedded room from the embedder. What it protects is (a) the platform-wide guarantee that *Witbitz* can't read content, and (b) the room from your page's *other* scripts and trackers. ## Who may embed — two gates Embedding is **off by default** (`frame-ancestors 'self'`). Turning it on is two layers, deliberately: - **`frame-ancestors` — the app-wide COARSE gate** (browser-enforced). It *has* to be coarse: the browser evaluates the header before any JavaScript runs, when the room isn't even known (especially for `#mk=` fragment links). Set the allowed embed origins for a deployment via `SPACE_EMBED_ORIGINS` (it feeds both this header and the client check from one source, so they can't drift). - **The owner policy — the per-room PRECISE gate** (client-enforced). The owner's **signed** `AppPolicy` may name `embedOrigins` — the origins allowed to embed *that app's* rooms. Because the client holds the room key, it reads the sealed, signed policy itself and **closes the bridge** if this embedder isn't on the list. The server never sees it — content-blindness is preserved. On the hosted multi-tenant platform the owner key is resolved from the tenant registry (`op:'policy'`); on a pinned single-owner deployment it's the baked key. So the browser stops any non-registered origin from framing at all; the owner policy narrows it to *this owner's* origins per room. Fail-closed. ## Orthogonal to what the Space *is* The embed is transport + isolation. Whether the client is *trustworthy code* is a separate axis — [the attested client](./client-architecture.md#the-attested-client); admission and tiers are [the owner rule](./identity-and-admission.md#the-owner-rule). They compose: an embedded Space is still content-blind, still owner-governed, still verifiable. One line: **`witbitz.chat/embed.js` drops a real, isolated, content-blind Space into any page, talks to it over a narrow origin-checked bridge that never carries your plaintext, and lets the room's owner decide who may embed it.** --- # Run an agent turn An async Space does not keep an agent process alive. A member posts a turn; the render opens the sealed room state, runs the agent, writes the reply, and goes cold again. That is the core runtime loop. --- ## The turn flow
One request-scoped render
Member sends turn with room key and message
->
Render checks admission, opens the ledger, runs tools, re-seals, and drops the key
1. A member sends `{ op:'turn', room, mk, message }`. 2. The transport checks `commit(mk)` against the Space record. 3. If the Space is gated, identity is verified against the sealed admission policy. 4. The render opens the sealed ledger. 5. The member entry is appended. 6. The agent runs with the Space's persona and tools. 7. The reply, widgets, files, or pending proposals are appended. 8. The ledger is re-sealed and stored. 9. The room key is dropped. Read the deeper version in [Async Spaces](./async-spaces.md). ## Minimal turn ```js const r = await client.turn(room, mk, { from: 'Ada', text: 'Summarize our plan and list the unresolved decisions.' }) ``` For an open Space, that is enough. ## Signed member turns A signed turn proves which admitted member wrote the entry. The client signs the entry core and sends the signature with the message. For an email-gated Space, the message also carries an ID token and the member signing public key. Conceptually: ```js await client.turn(room, mk, { text: 'I approve the itinerary.', }, { email: 'ada@example.com', googleIdToken, sign: { priv: memberPrivateKey, pub: memberPublicKey } }) ``` The render verifies the identity and stores only the verified attribution, not the raw ID token. ## When the agent answers A turn always lands in the ledger. Whether the agent *replies* is a separate decision, controlled per Space from the composer: | Mode | Behaviour | |---|---| | `always` | Reply to every message. | | `auto` *(default)* | Reply if the Space is solo or the message addresses the agent. In a **group** message not aimed at it, a cheap model gate decides whether the message wants help. | | `ondemand` | Reply **only** when addressed by name or `@`-mention; otherwise the message is stored silently. | A silent turn is not a failure: it returns `silent: true` with the entry count, so the client can drop its "thinking" indicator immediately rather than waiting for a timeout. Two things override the mode. A venue that hard-mutes the agent (`agentReplies: false` — an observer room) always wins. And if the chime-in gate cannot be reached, it **fails open** and the agent answers: an unavailable model must not silently turn a group Space mute. > The gate is the reason a Space's tests should not depend on ambient model credentials — with a key present it makes a > real call, and the model's verdict then decides whether a reply exists. ## Tools during a turn A Space's tools are declared in its sealed config. During a turn, the agent can call those tools. Tool outputs may become: - plain text in the reply - a shared widget such as a map, chart, diagram, itinerary, or flights card - a file attached to the reply - a pending proposal requiring human approval High-stakes calls are not run directly. They become proposals under [Delegated authority](./trust-model.md#delegated-authority). ## Reads after a turn Clients normally watch a Space with `pollSealed`. When the sealed ledger changes, the client opens it locally and renders the new entries. ```js const r = await client.pollSealed(room, mk, previousEtag, previousCount) ``` For gated Spaces, read operations also include the caller's identity so the server can refuse unauthorized reads before returning sealed content. ## What plaintext exposure means In the current production tier, the server-side render receives `mk` during turns. That is why the model can reason over the Space. The key is request-scoped and dropped afterward, and storage remains sealed to your key. This is not the same as "the operator can never see plaintext in use." The attested server tier makes that stronger property checkable, and it is now **in production**: turn a Space's enclave on and its turns run inside measured Nitro hardware with the key sealed to that image. Enabling it is the app builder's per-room choice. Read: [Status](./status.md), [The double blind](./trust-model.md#the-double-blind), [The attested tier](./the-attested-tier.md) ## Code map | Concern | File | |---|---| | HTTP turn op | `agent/spaceHandler.mjs` | | Runtime service | `agent/spaceService.mjs` | | Decrypt-once render | `agent/asyncTurn.mjs` | | Agent tool loop | `agent/brain.mjs`, `agent/brainTools.mjs` | | Delegated authority | `agent/delegatedAuthority.mjs` | | Client turn wrapper | `spaces/public/spaceClient.js` | --- # Tools and widgets In Witbitz, tools are how an agent reaches capability, and widgets are how tool results become shared application state. The important distinction is that a widget is not one user's private chat decoration. It is part of the Space that every member can see. --- ## Tools are declared by the Space A Space's sealed config names the tools available to the agent: ```js await client.create({ agentName: 'Planner', persona: 'Plan trips with the group and keep decisions clear.', tools: ['search_places', 'show_places', 'show_chart', 'write_pdf'] }) ``` The render opens this config during a turn and exposes the declared tools to the agent. If the config is sealed, the platform cannot read the persona, tool list, admission policy, or model config. ## The built-in catalogue These are the tools a hosted Space can name in its config today. A Space only gets what it declares — an empty `tools` list is an agent that can talk and nothing else. | Tool | What it does | Produces | |---|---|---| | `search_places` | Query Google Places for candidates. | Text — the agent then picks | | `show_places` | Put chosen places (by id) on the shared map. | `places` widget | | `add_place` | Add one place to the existing map doc. | same `places` widget | | `search_flights` | Query Duffel for real offers. | Text — the agent then picks | | `show_flights` | Put chosen offers (by id) on the shared card. | `flights` widget | | `set_itinerary` | Replace the whole day-by-day plan. | `itinerary` widget | | `add_to_itinerary` | Append to the existing plan. | same `itinerary` widget | | `show_chart` | Draw data as SVG. | `chart` widget | | `show_diagram` | Draw a relationship graph as SVG. | `diagram` widget (same page) | | `show_photo` | Put an image in the reply. | Sealed out-of-line blob | | `read_file` | Read a shared PDF / Word / Excel / CSV. | Text | | `read_url` | Fetch a public URL (SSRF-guarded). | Text | | `write_pdf` | Generate a PDF from Markdown. | File on the reply | **Query-then-show is deliberate.** `search_*` returns candidates to the agent; a second call puts the *chosen* ids on the shared doc. The member sees a curated set, not a raw result dump, and the agent's choice is the part that gets sealed into the room. Two widget kinds are **not** agent-produced: `location` (a member shares live location from the composer) and `nav` (turn-by-turn, opened from a place card). The client can also render `trip` and `shop` docs, which no current first-party tool emits. > Any shared view can be opened full-screen from the room's **⋯** menu — a widget is a living document, so reaching it > must not depend on scrolling back to the message that first posted it. ## Tool results become room artifacts Tools can return different kinds of results: | Result | Example | |---|---| | Text | A searched page summary or calculation result. | | Widget | A map, chart, diagram, itinerary, route, or flights card. | | File | A generated PDF attached to the agent reply. | | Image/blob | A sealed out-of-line photo or generated image. | | Proposal | A high-stakes action waiting for human approval. | The agent reply can carry pointers to widgets or files created during the same turn. The client renders those pointers as live shared UI. ## Shared widget state The `state` op stores a named shared widget document. A member or tool can set a patch and summary; later reads return the latest state. The state is sealed with the room key. ```js await client.state(room, mk, 'trip-plan', { days: [ { name: 'Day 1', items: [{ text: 'Arrive and check in' }] } ] }, 'One-day starter itinerary', 'Ada') ``` Because the document belongs to the Space, every member sees the same widget state. ## High-stakes tools become proposals An agent should not directly run actions that spend money, change external systems, or cross a policy boundary. Instead, the turn records a proposal. A human later approves or denies it: ```js const pending = await client.pending(room, mk) await client.decide(room, mk, pending.proposals[0].id, true, 'Ada') ``` Only an approved proposal executes, and only once. Read: [Delegated authority](./trust-model.md#delegated-authority) ## Air-gap behavior When `AIRGAP=1` is enabled in an on-prem deployment, internet-touching tools are refused server-side. Local tools such as chat, document reading, PDF generation, charts, and diagrams continue to work. Map/photo/search/flight tools need either local replacements or must stay disabled. Read: [Enterprise and on-prem](./deployment-options.md#your-boundary) ## Code map | Concern | File | |---|---| | Tool declaration and validation | `agent/affordance.mjs`, `agent/agentRecord.mjs` | | Space tool execution | `agent/spaceHandler.mjs`, `agent/spaceService.mjs` | | Shared widget docs | `agent/sessionStore.mjs`, `spaces/public/spaceClient.js` | | Widget renderers | `spaces/public/*Widget.*` | | Delegated actions | `agent/delegatedAuthority.mjs` | **See also — the inverse direction:** a widget puts *your app's UI inside a room*; [Embed a Space](./embed.md) puts a whole *room inside your app's page* (a cross-origin iframe + an origin-checked bridge, content-blind either way). --- # Async Spaces — the agent is a function, not a server The first Witbitz Spaces are **async**: a persistent room that lives over weeks, where the AI is a **function invoked per turn**, not an always-on process. Between turns the Space is cold ciphertext — so an idle Space costs ≈ $0. This is the model the [Spaces app](https://witbitz-spaces.pages.dev) runs on. **Live in production.** > See also: [the Space link is the auth](./identity-and-admission.md) (membership + identity) and > [the double blind](./trust-model.md#the-double-blind) (why the platform can't read a Space). --- ## 1. Agent-as-function A live call runs the agent as a container for the call's whole life. An async Space instead invokes a **turn function** only when a present member acts: > A member posts → the transport invokes the turn with the member's `mk` → the function opens the sealed ledger, > asks the model, appends the reply, re-seals, stores, and drops `mk`. Then the Space is cold again. No held socket, no always-on compute → **idle ≈ $0** (the reason to prefer poll/push over held connections; cost model in the repo's `async-rooms-cost.md`). It stays on AWS (DynamoDB ledger + S3 blobs + Bedrock/LLM in-boundary) so the ciphertext and the inference share one trust boundary. ## 2. The sealed ledger A Space's conversation is one **snapshot sealed under `mk`** and stored as ciphertext (`sessionStore.putLedger`/`getLedger` → `agent/envelope.mjs` `seal`/`open`). It is sealed to the **member key only** — there is **no operator recovery key**. The platform holds no key; to it, a Space is opaque bytes. This is the persistent Space memory. > **How it is sealed.** v2 re-seals the whole ledger on every turn — simple, and O(n) in the history. v3 appends the > new entries as **per-entry sealed boxes**, so appending is O(1) in the delta and a reader can take a content-blind > slice without opening everything. v3 is what production runs; the writer is flag-gated (`SPACE_LEDGER_V3`) and > migrates on write, so the two formats coexist and the change is reversible. ## 3. The turn — a decrypt-once render `agent/asyncTurn.mjs` `runAsyncTurn` is the render, and it is the **one place plaintext exists**: 1. **access control** — `mk` arrives from a present member's client; `agent/spaceTransport.mjs` checks `commit(mk) === the Space's stored commitment` (proves membership + that this turn seals to the ledger's key). 2. **attribution** (for a signed Space) — verify the turn's signature against the member grant, set the **verified** author ([room-link-auth §4](./identity-and-admission.md)). 3. **decrypt-once** — open the sealed ledger, append the incoming entry, run the brain (its tools may produce widgets/artifacts), append the reply, re-seal, `putLedger`. 4. **drop `mk`** — the key is never held past the turn. Between turns nothing runs and nothing is readable. ## 4. Reaching members — poll, content-blind `agent/spacePoll.mjs` is the read side, and it stays blind on the common path: - The client sends its last **etag**; the server computes `etagOf(sealed)` = a hash of the **ciphertext** — it reveals only "changed", never the turn count, and it is computed **without decrypting**. - **Unchanged** → return no entries and **never decrypt** (`mk` unused). - **Changed** → decrypt-once, return only the **plaintext new entries** after the client's cursor, drop `mk`. The committed ledger's plaintext is produced **server-side** — the client is thin: it sends turns and receives **server-decrypted plaintext**. (Its only crypto is minting the key, opening the small in-flight *partial*, and the optional `pollsealed` read below.) **Away members are reached by push** (`agent/webPush.mjs`, ops `subscribe` / `unsubscribe`). The notification is a **content-free tickle**: it carries no message text, only "something happened in this room". The service worker wakes and polls — receiving server-decrypted plaintext, exactly as an in-app read — so the **push provider** learns nothing a poll would not already reveal; content-blindness *against the push provider* survives the notification path. This works in an installed iOS/Android home-screen PWA, with no native app. **Reads are gated too, for a private Space.** On an `admission:'email'` Space the poll (and *every* content op — `title`, `state`, `pending`, `decide`, `import`) requires an **allow-listed Google sign-in** before the server will decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes ([room-link-auth §4](./identity-and-admission.md)). Live + proven in prod: a token-less `poll` to an email-gated Space returns `403`. An **open** Space is unchanged — the link reads. ### The reply arrives progressively A turn does not wait for the model to finish. As the reply is generated it is **sealed and published as a partial** (`agent/asyncTurn.mjs` → `putPartial`), and that sealed partial rides **every** poll response — including the etag-unchanged short-circuit, so it is delivered without disturbing the content-blind fast path. The client opens it with `mk` and reveals it at a readable rate. The value is not a shorter wait. It is that the reader starts *processing* the answer sooner. The partial is cleared the moment the finished entry lands in the ledger, so a live bubble can never duplicate a real message — including on the failure path, where a stream that died after emitting text must also disappear. ## 5. The ops One HTTP surface (`agent/spaceHandler.mjs` → `agent/spaceService.mjs`), at `/space`: | op | what it does | |----|--------------| | `create` | register the public Space record (its key commitment + config + `gk`); the client mints room+mk, the server never sees mk | | `turn` | a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) | | `poll` | the default read — a **content-blind etag** over ciphertext detects changes, then the server returns a **decrypt-once plaintext** delta (plus any sealed partial); on an email-gated Space it requires an allow-listed sign-in | | `pollsealed` | the same history returned **still sealed** — the server takes no key and decrypts nothing; the client opens the envelope itself | | `decide` | approve/deny a pending proposal (delegated authority) | | `pending` | list pending proposals | | `import` | seed the Space from a public ChatGPT share link (SSRF-guarded) or a WhatsApp export | | `title` / `state` | a sealed room title; the shared co-edited widget doc | | `blob` | sealed out-of-line content (images, files). Content-addressed and immutable, so a client may cache a ref forever; the server never opens it | | `subscribe` / `unsubscribe` | Web Push registration for away members (content-free tickle) | | `view` | a **scoped read for an admitted agent**: decrypt with a present member's mk, keep only what the agent's `caps.perceive` allow, and re-seal that slice to the agent's box key. The agent never receives mk | | `fetchview` | the agent collects that sealed slice. Un-gated and content-blind — it is ciphertext only the agent's box private key opens | | `mirror` | append a linked room's entries to this ledger (the private-lane bridge). Deduped by `id`, no agent turn. **The server never joins the two rooms — the device does, holding both keys** | | `policy` | resolve a room's governance + owner key, so a client can verify the room against its own pinned owner *before* trusting it | | `attest` | the running service reports the source it was built from (verifiable trust); cross-check against the signed build certificate | | `invite` | public, no mk: a joining partner fetches the commitment + the sealed share, unseals it with the invite code and combines it with their own share to reconstruct mk. The server only ever hands back ciphertext | ## 6. What runs inside A Space's capabilities are ordinary **tools** in its config (e.g. `search_places` → a shared map widget, `search_flights` → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific twist: a **high-stakes** tool call is deferred to a human approval instead of running in-room — that is [delegated authority](./trust-model.md#delegated-authority). ## 7. Code map | Concern | Where | |---|---| | Turn render (decrypt-once) | `agent/asyncTurn.mjs` (`runAsyncTurn`, `renderLedger`) | | Access control + mk lifecycle | `agent/spaceTransport.mjs`, `agent/spaceService.mjs` (`handleSpaceRequest`) | | Content-blind poll | `agent/spacePoll.mjs` (`etagOf`, `pollSpaceView`) | | Sealed ledger | `agent/sessionStore.mjs`, `agent/envelope.mjs` (`seal`/`open`/`commit`/`newRoomKey`) | | HTTP handler + record store | `agent/spaceHandler.mjs` | | Thin client | `spaces/public/spaceClient.js`, `spaces/public/space.html` | | Delegated authority | `agent/delegatedAuthority.mjs` ([doc](./trust-model.md#delegated-authority)) | ## 8. Status Live in production: the `/space` **zip** Lambda (agent brain as a function) + the Spaces app at `witbitz-spaces.pages.dev`. Shipped: the turn core; transport (mk-commit); content-blind poll; delegated authority; per-member signed identity; progressive (streamed) replies; Web Push for away members; the 2-of-2 code-sealed couple handshake (`spaces/public/invite.js` — the server only ever holds ciphertext under a code it does not have); private lanes; membership forks; email-gated admission; import from ChatGPT and WhatsApp; the built-in tool catalogue. **Ledger v3** — appending only the new entries as per-entry sealed boxes, so a turn's crypto is O(1) in the delta rather than O(n) in the whole history — is **enabled in production** (`SPACE_LEDGER_V3=1`) while the code default stays off, so a rollback is one environment variable. v2 seals the whole blob each turn and remains the fallback. Deferred: delivering an agent's scoped view while **no member is present** (a push-on-turn sealed-view store); compaction of very long ledgers beyond the current bound. --- # Trust model Witbitz makes **two separate trust claims.** Keep them separate: 1. **Privacy is verifiable — the double blind.** A Space is sealed to a key the platform never holds; the app has no backend of its own; the browser egress footprint is locked; and the deployed render can be rebuilt from published source and checked against a signed certificate. Neither the operator nor the app's own owner can read the users' data — and that's a *property you check*, not a promise. 2. **Humans keep authority — delegated authority.** An AI agent can propose work, but the runtime requires a human approval before any high-stakes action executes. No action runs without an approval. This page states both guarantees in full. It is intentionally explicit about what is live today and what is not — start with [Status](./status.md) if you need the shipped / preview / designed map, and [Verify it yourself](./verify.md) to run the checks in your own terminal. --- ## What is protected today | Claim | Production status | |---|---| | A gated Space refuses reads and writes without an allow-listed identity | Live and checkable | | The stored Space ledger is ciphertext sealed to the room key | Live and checkable | | There is no operator recovery recipient in the production envelope | Live and checkable | | The app's browser egress is locked to a published allowlist | Live and checkable | | The deployed render publishes a signed build certificate | Live and checkable | | The render source can be rebuilt to the deployed hash | Live and checkable | | High-stakes agent actions require approval | Live and tested | Run the checks: [Verify it yourself](./verify.md). ## What is not hidden Witbitz does not hide everything, and the docs should never imply that it does. - **Metadata remains visible.** The platform sees that a room exists, when it changes, and roughly how large writes are. - **The model provider sees turn content — on the frontier trade.** A remote frontier model must receive the text it reasons over, so a room that keeps the frontier model (the default Space) exposes its turns to that vendor. The **confidential/attested tier closes this**: its model call leaves the measured image only through an attested no-log gateway into confidential-compute inference on an **open-weights** model (fail-closed if that gateway does not attest) — see [the attested tier](./the-attested-tier.md). - **The render sees plaintext in use today.** Production Spaces open plaintext inside the server-side render during a member's active read or turn. - **Endpoints still matter.** A compromised member device can read what that member can read. - **Availability is not privacy.** The operator can refuse service. The content-blind claim is about reading, not guaranteeing access forever. --- ## Guarantee 1 — The double blind {#the-double-blind} Witbitz's core privacy claim: for an app built on it, **neither the platform operator nor the app's own owner can read the users' data** — and, the part that makes it a *property* not a promise, that claim is meant to be **checkable from the code**, not taken on faith. What holds today, exactly where it stops, and how the remaining "readable in use" gap is closed by the [attested tier](./the-attested-tier.md), now in production. ### Sealed to a key we never hold → the platform is blind A Space's data is a snapshot **sealed under the room key `mk`** and stored as ciphertext; the platform never holds `mk` (`agent/envelope.mjs` `seal`/`open`; the key is committed only as `commit(mk)`). To the operator, a Space is opaque bytes — storage and transport are **content-blind**, because the platform holds no key. There is **no operator recovery key**: every record is sealed to the room key alone, so there is nothing on the platform that can read it. The double blind is the only configuration that runs in production. ### The decrypt-once render → the one plaintext door Plaintext opens in exactly one *code path* — the certified render — but that path runs on **two** triggers: an **AI turn** (open the ledger with a *present* member's `mk`, run the declared program, re-seal, **drop `mk`**) and every **changed read** (the poll decrypts the new entries to return them, because the client is thin and decrypts nothing itself — [async Spaces](./async-spaces.md)). Reads outnumber turns, so the honest exposure surface is *the render*, not "only when the AI acts." The key reaches that render only from a **present** member's client, per active window — never from storage — so the operator can act on the data only while a member is present, and learns nothing when no one is. **The honest boundary, stated plainly.** Because the client is thin, `mk` travels to the render on *every* read and write, and the server opens the ledger in place — a **decryption oracle** for the moments a member is active. So *content-blind* means **at rest and against a passive (honest-but-curious) operator**, not one that captures `mk` in flight; and because the whole ledger is sealed under one long-lived room key, an operator that logged `mk` once could read that Space's history past and future — there is no per-Space forward secrecy yet. That boundary is the reason the **attested tier** exists (below). Keeping it **one** small, auditable path is exactly what makes attestation tractable. ### Backendless → the app owner is blind too The second blind is the one nobody else offers. A Witbitz app is a **static page with no backend of its own** that connects *only* to the audited Witbitz API. So the app's *developer/owner* has no backend through which to read its users either: the users' content is sealed to a key the owner doesn't hold, and the app has nowhere else to send it. Data is encrypted **from** the owner, not merely **to** them. To be exact — "backendless" is a claim about the *app*, not the platform. **Witbitz is still a backend** (one shared, constrained, inspectable platform). What's removed is each developer's *own, opaque, app-specific* server. So the trust surface isn't smaller in a hand-wavy "no infrastructure" sense — it's **consolidated**: one auditable platform to check, instead of N unknown app backends. That consolidation is exactly what makes the footprint declarable. ### Verifiable → check the code, not the operator Because the app is a static page with a declared egress (it can only talk to the Witbitz API), its **entire data footprint is inspectable and declarable**. You verify the code and a signature, not a "trust us" policy. Four of those checks run in your own terminal **today** — the admission gate enforcing, the egress lock, the content-blind store, and the deployed render being a reproducible build of published source. [Verify it yourself](./verify.md) is the commands. What is *not* yet checkable about your own traffic is the render being unobservable **in use**; that gap is closed cryptographically only in principle (a never-receives-`mk`, append-only tier whose sealed client→actor channel is unbuilt and not planned) — the shipped way to close it is the [attested tier](./the-attested-tier.md). ### Status — what holds today | Property | Status | |---|---| | Sealed ledger, platform holds no key (nothing on the platform can read it) | **shipped** | | No operator recovery key (sealed to the room key alone) | **shipped & CHECKABLE** — `POST {"op":"sealed"}` → `recipients:["room"]` ([verify Test 5](./verify.md)) | | Admission gate enforces (reads *and* writes refused without an allow-listed identity) | **shipped & CHECKABLE** ([verify Tests 1–3](./verify.md)) | | Decrypt-once render, `mk` from the client only, dropped after | **shipped** | | Backendless static app (no backend of its own) | **shipped** | | Egress-lock (CSP `connect-src`/`script-src` allowlist) | **shipped & ENFORCED** (block-mode) ([verify Test 4](./verify.md)) | | Client E2EE primitive (the sealed-blob envelope) | **shipped** | | Signed build **certificate** (deployed build hash + egress, signed from ground truth) | **shipped & CHECKABLE** — `/cert.json` ([verify Test 6](./verify.md)) | | **Reproducible build** of published source (rebuild it → the deployed hash) | **shipped & CHECKABLE** — `/source.tar.gz` ([verify Test 7](./verify.md)) | | Attested tier (operator provably blind **in use**) | **Production** — verifiable on real Nitro hardware, scale-to-zero; the app builder enables the enclave per room ([attested tier](./the-attested-tier.md)) | | Attested **client** (a hardware-attested native shell carrying the audited bundle) | **Design** — native shells not shipped; the deployed web app *already* verifies the enclave's attestation, which is part of the attested tier above ([verified client](./client-architecture.md)) | ### The moat — honestly The moat is **not** "incumbents *can't* do this." They can: Apple's Private Cloud Compute ships attested confidential inference today, and a competent team could match everything Witbitz has shipped in a quarter. Two things are actually defensible: - **Neutral + backendless.** A vendor cloud reads *to itself* by default; a Witbitz app has no backend of its own and isn't bound to one provider's stack. The trust surface is **consolidated to one auditable platform, not removed** — the honest claim, *not* "no backend" (Witbitz **is** the backend, singular and inspectable). Awkward for an incumbent whose business *is* the readable backend to adopt — but not something they're *barred* from. - **The verifiability layer.** The signed certificate and the reproducible build have **shipped**, and the attested tier is **in production** — verifiable on real Nitro hardware, launch-on-demand and scale-to-zero. Four checks run in a stranger's terminal today, and the fifth is in production for any Space the app builder runs in the enclave. What remains genuinely unbuilt is the attested **client** in native shells. ### Code map | Concern | Where | |---|---| | At-rest ledger seal (**symmetric, under `mk` only**) + key commitment | `agent/envelope.mjs` (`seal`, `open`, `commit`, `newRoomKey`); `agent/sessionStore.mjs` `encodeMemory` — one recipient, `mk`. **No operator/beta/admin recipient in prod.** | | Recipient sealing (**ECDH `sealTo`/`openBox`**) — seals to a **member/agent** public key, *never an operator* | scoped agent reads (`agent/spaceScopedView.mjs`), Bridge epoch keys (`agent/bridgeMembership.mjs`), the collection-blob — a **separate** mechanism from the at-rest ledger above | | Sealed ledger + decrypt-once render | `agent/sessionStore.mjs`, `agent/asyncTurn.mjs` | | Content-blind poll (etag over ciphertext) | `agent/spacePoll.mjs` | | Egress-lock CSP (**block-mode, enforced** on prod Spaces) | `spaces/tools/gen-space-csp.mjs` | | The render, published and readable (~600 lines) | [the published render source](https://github.com/kibitz-chat/witbitz-render) | *The double blind is not "trust us less." It is "you don't have to" — sealed to your key by default, and provably unobservable in use for any Space that turns on the attested tier.* --- ## Guarantee 2 — Delegated authority {#delegated-authority} The second guarantee. An agent never *owns* authority — it **receives** it, limited and revocable, and a human approves anything high-stakes before it happens. The runtime enforces one invariant: **no action runs without an approval.** > Built + tested (async Spaces M2 Cut 4); runs inside the deployed `/space` render. Code: `agent/delegatedAuthority.mjs`. ### Propose → approve → execute An effectful action moves through three recorded steps, asynchronously (the human may approve on a *later* turn, not in-the-moment): 1. **propose** — on its turn, the agent proposes an action; it is recorded **pending**, **not run** (`propose(room, { action, by })`). 2. **approve / deny** — a human decides on a later turn (`decide(room, { id, approve, by })`). A decision on a non-pending proposal is refused; a decided proposal can't be flipped. 3. **execute** — only an **approved** proposal runs, through the injected executor (`execute(room, { id, run })`), and only once. ### The invariant Enforced and unit-tested: **an un-approved proposal never runs, and no proposal double-runs.** Approval is a precondition to execution, checked in the runtime — not left to the model's discretion. The model reasons, the human authorizes, the runtime gates. ### Capabilities are just tools A Space's effectful capabilities are ordinary **tools** in its config (`send_summary`, `schedule_checkin`, …) — there is no separate "actions" concept. The turn runs the normal brain tool-loop; the only async-specific twist is that a high-stakes tool call is **deferred to a human approval** instead of running in-room. On approve, `execute` runs the same proposal through the **same tool executor** a live call would use, keyed on the tool name — so approval routes to the real implementation. ### The audit trail Every step — propose / approve / deny / execute (with result or error) — is appended to a single per-room **`authority` doc**, an append-only log. It is **mk-sealed** like the ledger, so it is **content-blind** (the platform can't read it) and it **persists across weeks**. A wrong `mk` yields nothing — the trail is readable only to a present member, in the decrypt-once render. Richer policy — spend caps, per-capability authority tiers, org-level delegation — builds on this primitive. The one-method-call SDK surface for it is [Delegated authority in the SDK](./sdk.md#private-lanes-and-human-approved-actions). --- ## Identity and admission A Space starts with **key possession**: holding the room key proves membership. Gated Spaces add signed identity on top — Google/OIDC identity, signed invites, agent keys, owner policies. The link is the admission capability; the owner decides who may join and at what tier; and members join, rotate devices, and are revoked over a Space's life. The full mechanism — identity, the verified room, admission tiers, the owner rule, member lifecycle, and recovery — is one doc: **[Identity and admission](./identity-and-admission.md)**. ## Stronger tiers | Tier | Status | What it changes | |---|---|---| | **Owner-governed rooms** | **Implemented** | The app owner can mandate admission tiers and capabilities; built and tested, not yet enabled in production ([owner rule](./identity-and-admission.md#the-owner-rule)). | | **Attested server tier** | **Production** | A Space's turns run inside measured Nitro hardware, key sealed to the image; launches on demand, scales to zero when idle. The platform can run any room in it; enabling it is the app builder's choice ([attested tier](./the-attested-tier.md)). | | **Attested client** | **Design** (native shells) | The deployed web app *already* verifies the enclave's attestation and pins its measurement before sealing your key — that half is live, part of the attested tier; a *native* binary carrying the audited bundle is still future work ([verified client](./client-architecture.md)). | For the full mechanism-by-mechanism breakdown — the canonical maximum, the orthogonal privacy axes, the named modes, and the feature × mode matrix — see the privacy spine: **[Privacy tiers](./privacy-tiers.md)**. ## Where to go next - Check the claims: [Verify it yourself](./verify.md) - Configure privacy: [Privacy tiers](./privacy-tiers.md) · the first-party [Spaces app defaults](./spaces-app-defaults.md) - Understand admission and lifecycle: [Identity and admission](./identity-and-admission.md) - Go unobservable in use: [The attested tier](./the-attested-tier.md) - Know the code on the user's side: [The verified client](./client-architecture.md) --- # Privacy tiers — the canonical configuration, and how modes trade down This is the **spine** for the privacy story. Every other doc describes a *feature*; this one describes the **maximally-private configuration once**, then defines each real-world mode as a **delta** from it — what it *gains* and, precisely, what it *leaks and to whom*. Read this first; the feature docs hang off it. > **Honesty rule (read before quoting anything here).** This page describes the **ceiling** — the maximal configuration, including mechanisms that are still **Design**. **What is actually in production today is in the [Status](./status.md) table and what you can check yourself is in [Verify it yourself](./verify.md).** Every mechanism below carries its own maturity (**Production / Implemented / Design**) so the ideal is never mistaken for the real. --- ## 1. The always-on foundation (every Space, live, not optional) These are not a "tier" — they are the floor beneath every Space, live and checkable today: - **Admission gate** — a gated Space refuses reads *and* writes without an allow-listed identity, even with the room key. - **Egress lock** — the browser-visible app can reach only a published allowlist (`connect-src`); everything else is refused by the browser. - **Content-blind storage** — the store holds only ciphertext sealed to the room key; there is no operator recovery recipient. - **Signed, reproducible build** — each origin — the deployed render **and** the mk-holding vault/kernel — publishes a `/cert.json` + assets-manifest binding its exact deployed bytes to published source, which rebuilds to that hash. *(Store origin + a CI rebuild-and-compare job still to come.)* This is the keystone: it is what makes the claims above *checkable* rather than asserted. - **Key confinement (vault sandwich)** — room keys live in the L1 vault + Kernel worker; the app frame is keyless, enforced by Same-Origin Policy, not app discipline. See [Client architecture](./client-architecture.md). Everything in §2 layers *on top* of this foundation. --- ## 2. The canonical maximum — the "Maximum" tier The highest trust level is the foundation **plus all seven** of the mechanisms below, each of which closes **one specific leak against one specific party**. They are **orthogonal** — independently switchable, each shutting a different door — so the "levels" in §5 are *bundles* of these, not a single ladder. ``` Foundation (always on) + Server attestation → operator/cloud can't read plaintext in use + Client attestation → the running client is provably the genuine build + Client verification → the locks are provably enforced in YOUR browser + OHTTP → the server can't see WHO (your IP) is connecting + Client shaping → a network observer can't read sizes / timing / patterns + No notification → no push provider learns THAT / WHEN you got a message + Confidential model → the model that answers the turn can't read it either = Maximum ``` > **The platform ships this; apps configure it.** The "Maximum" above is the platform's **supported capability**, not an aspiration — everything except the roadmap items (client attestation and client shaping) is shipped and available to every Space. A *mode* (§5) is a **bundle** of these mechanisms; a lower mode simply omits some. *Which mode the first-party Spaces app ships at is a separate, app-level concern — see [The Spaces app: default configuration](./spaces-app-defaults.md).* --- ## 3. What the platform ships today Mechanism-level view — the platform's status for each. (What the *first-party Spaces app* enables by default is a separate concern: [The Spaces app: default configuration](./spaces-app-defaults.md). Full product map: [Status](./status.md). Client-side checks: [Verify it yourself](./verify.md).) | Mechanism | Platform maturity | |---|---| | Admission gate | **Production** (structural — every Space) | | Egress lock | **Production** (structural — every Space) | | Content-blind storage | **Production** (structural — every Space) | | Signed + reproducible build | **Production** (structural — every Space) | | Key confinement (vault sandwich) | **Production** (structural — every Space) | | **Server attestation** (Nitro enclave) | **Production** — the app builder enables the enclave per room | | **Client verification** (verifier extension / CSP-loader) | **Implemented** — the self-run tool + the Windows verifier extension are built and proven on staging, not the prod default; the mobile companion is **Design** | | **Client attestation** (hardware-rooted binary/device proof) | **Design** — iOS App Attest / Android Keystore / Windows TPM verifiers are stubbed + fail-closed; native shells not shipped. (Client *verification* above is *software* release-attestation — a separate, more-mature thing.) | | **OHTTP** (network-metadata) | **Production** — oblivious transport (RFC 9458) is LIVE: the app routes room ops by postMessage to a pinned open-source **L1.5** frame that verifies a **client-attestable relay** (GCP Confidential Space, `relay.witbitz.chat`) and encapsulates to the gateway. Sticky opt-in (`?ohttp=1`); every room data op goes through the relay. ([verify](./verify.md)) | | **Client shaping** (traffic-analysis) | **Design** | | **No notification** | Push is **Production** (off unless a user enables it — a configuration choice) | | **Confidential model** (attested inference) | **Production** — the confidential tier routes its model call through an attested no-log gateway into confidential-compute inference on an open-weights model, verified transitively from the render enclave's measurement ([the attested tier](./the-attested-tier.md#the-confidential-model-closing-the-last-reader-shipped)). | Everything above is **Production** today except **client attestation and client shaping**, which are **Design** — the gap between what ships and the full ceiling. (OHTTP is **Production**, off by default behind the "Hide my IP" toggle.) --- ## 4. The seven mechanism axes (orthogonal) Each axis: the leak it closes, the party it closes it against, and its **platform state**. | Axis | Closes this leak | Against whom | Platform state | |---|---|---|---| | **Server attestation** | plaintext readable *during the render instant* | the operator + the cloud (AWS) | **Production** (the app builder enables the enclave per room) | | **Client attestation** (hardware) | a participant's client is a **tampered/forged build** masquerading as the genuine app — a modified client that leaks, or a bot impersonating one | **whoever runs a forged/tampered client** in your room (checked by the room's `requireAttestation` gate — hardware proof via App Attest / Keystore / TPM) | **Design** — App Attest / Keystore / Windows-TPM verifiers stubbed, fail-closed | | **Client verification** | the JS actually running in your browser might not be the audited, egress-locked build — a **leaky client** could exfiltrate `mk`/plaintext | **whoever serves your client — the app builder itself**, the operator, a CDN, or a network injector — a leaky or swapped build, **on purpose or by mistake** (verifying the client is what makes the *double blind* — even the app's own owner can't read your content — hold) | **Implemented** — self-run tool + Windows extension proven on staging; mobile companion **Design** | | **OHTTP** | your IP / the fact you're connecting | the server + the network path | **Production** — a single attested relay (Confidential Space, verified client-side via the pinned L1.5 frame, RFC 9458) sees your IP but only ciphertext; the gateway decrypts the op but sees the relay's IP. LIVE for **all app→AWS traffic**: room data ops *and* the enclave attest/wake path (previously a direct-to-gateway probe) now both ride the relay when the toggle is on — fail-closed, so a relay hiccup never silently falls back to a direct IP-revealing POST. The relay is measured code that provably does not log the source address — verifiable, not a promise. Residual: the browser still reveals your IP to **Cloudflare** when it loads the app's own bytes and calls same-origin edge functions (`/space-config`, tiles, `/api/appshare`, `/api/link`) — Cloudflare already serves the app, so this is no *new* party, and AWS never sees you (CF proxies onward from its own IP). Hiding your IP from Cloudflare/Apple/Google (app-load, push, Google sign-in) is outside any web page's control — only a device-level VPN or Tor closes it, which composes cleanly with OHTTP. | | **Client shaping** | message sizes, timing, access patterns | a passive network observer | **Design** | | **No notification** | *that*/*when* you received a message (content-free tickle + timing) | the push provider (Apple/Google/push service) | **Production** | | **Confidential model** | the turn's plaintext, read by the model that answers | the model vendor / host | **Production** — the confidential tier's measured image routes only to an attested no-log gateway (Intel-TDX-verified in-image, fail-closed) into confidential-compute inference (Intel TDX + NVIDIA GPU, gateway-verified); trades frontier for open-weights ([the attested tier](./the-attested-tier.md)) | > **Note — client verification** comes in three forms: the self-run **tool** (on-demand; [Verify it yourself §8](./verify.md)), the Windows/desktop **extension** (continuous — pins the release + forces the CSP; **Implemented**), and a mobile **native companion** (**Design**). "Client-verify on" in §5 means the extension/companion (continuous) or a tool run (on-demand). This is *software* release-attestation — distinct from **client attestation** above, which proves the *binary/device* is genuine via hardware. See: [The attested tier](./the-attested-tier.md), [The attested client](./client-architecture.md#the-attested-client), [The power-user stack](./client-architecture.md#the-power-user-stack), [The verifier](./client-architecture.md#the-verifier-extension), [Away notification](./status.md). --- ## 5. Named modes = bundles of axes Modes are named **bundles** of the §4 mechanisms — the platform supports the Maximum; a lower mode simply omits some. Names encode the **trade**, not a number. (Which mode the first-party Spaces app ships at is documented separately: [The Spaces app: default configuration](./spaces-app-defaults.md).) `●` on, `○` off, `◐` Design (not built yet). | Mode | Server-attest | Client-attest | Client-verify | OHTTP | Shaping | Confidential model | Notifications | In production today? | |---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|---| | **Maximum** (full platform support, §2) | ● | ◐ | ● | ● | ◐ | ● | off | Partly — client shaping / hardware client-attest are **Design** (OHTTP is Production; on in this mode, opt-in in the app) | | **Attested** (strongest shippable) | ● | ○ | ● | ○ | ○ | ○ | off | **Yes** | | **Verified** | ○ | ○ | ● | ○ | ○ | ○ | off | **Yes** | | **Baseline** (foundation only) | ○ | ○ | ○ | ○ | ○ | ○ | off | **Yes** | | **Connected** (convenience) | ○ | ○ | ○ | ○ | ○ | ○ | on | **Yes** | > Governance overlays (regulator access, full audit trail, owner rule) are **orthogonal** to these privacy modes — a compliance opening is a *named-recipient* disclosure, not a privacy-mode step-down. Keep them on a separate axis; see [Delegated authority](./trust-model.md#delegated-authority), [The owner rule](./identity-and-admission.md#the-owner-rule). Air-gapped / on-prem deployments compose *on top* of any mode — see [Air-gap](./deployment-options.md#air-gap-zero-egress), [On-prem](./deployment-options.md#your-boundary). --- ## 6. Feature × mode matrix Cells: **✓** available and privacy-neutral · **✓·leak(party)** available but discloses something specific · **✗** unavailable at this mode. Only rows that actually *trade* are shown — every other feature (posting turns, membership, recovery, offline export) is mode-invariant and simply **✓**. | Feature | Maximum | Attested | Verified | Baseline | Connected | |---|:--:|:--:|:--:|:--:|:--:| | Async chat, private lanes, documents, widgets, artifacts | ✓ | ✓ | ✓ | ✓ | ✓ | | Local connectors (on-device: folder / Obsidian / local MCP) | ✓ | ✓ | ✓ | ✓ | ✓ | | Away notifications (push) | ✗ | ✗ | ✓·leak(push provider: that/when) | ✓·leak(push provider) | ✓·leak(push provider) | | Web search / flights / places (egress tools) | ✗ | ✓·leak(the named API) | ✓·leak(the named API) | ✓·leak(the named API) | ✓·leak(the named API) | | Cloud connectors (Google Drive/Docs, etc.) | ✗ | ✗ | ✓·leak(the cloud: content synced) | ✓·leak(the cloud) | ✓·leak(the cloud) | | Live call / voice | ✗ | ✗ | ✓·leak(media path) | ✓·leak(media path) | ✓·leak(media path) | | External model answers the turn | ✓·attested route † | ✓·leak(model vendor) † | ✓·leak(model vendor) † | ✓·leak(model vendor) † | ✓·leak(model vendor) † | † **The model residual.** If an *external* model answers, the model vendor receives plaintext to compute — attestation covers the render, not the vendor. The **Confidential model** axis (§3–§4) closes this and is **Production on the confidential (attested) tier**: the render enclave's measured image routes the turn only to an attested no-log gateway into confidential-compute inference on an open-weights model, each link verifying the next (verification is transitive from the render measurement; the weights themselves are vouched by the attested stack, not measured — see [the attested tier](./the-attested-tier.md#the-confidential-model-closing-the-last-reader-shipped)). Modes that use a frontier model still leak the turn to that vendor — that is the frontier-vs-confidential trade, chosen per room. See [The double blind](./trust-model.md#the-double-blind). --- ## 7. Opting out — what each step gains and what it leaks Each step **omits one platform-supported protection** — an explicit, named trade. Read top-to-bottom as "relax one thing, gain one thing, leak one named thing to one named party." - **Maximum → Attested.** *Gain:* the egress tools (web search / flights / places) — real external capability. *Leak:* each tool call's content to that named API; and OHTTP/shaping being off means the network path sees your IP, sizes, and timing. *Party:* the specific tool API + a network observer. - **Attested → Verified.** *Gain:* runs on any deployment (no enclave required), lower cost. *Leak:* the operator + AWS can read plaintext *during the render instant* (server attestation off). *Party:* the operator + cloud. Client integrity is still verified. - **Verified → Baseline.** *Gain:* no verifier step, frictionless. *Leak:* you're back to trusting the server's *declaration* that the locks hold, rather than confirming them; cloud connectors become available (content to the cloud). *Party:* yourself (trust restored) + any cloud you connect. - **Baseline → Connected.** *Gain:* away notifications (and convenience integrations). *Leak:* the push provider learns *that* and *when* you received a message (content-free, but the tickle + timing). *Party:* Apple/Google/the push service. --- ## 8. Residual limits (the honest floor, at every mode) No mode removes these; state them plainly so the tiers never over-reach: - **Model vendor (frontier rooms only)** — a room that chooses a frontier model hands that vendor the turn's plaintext (§6 †). The **Confidential model** axis (§3–§4) that removes this is **Production**: on the confidential tier the LLM runs as confidential-compute inference (Intel TDX + NVIDIA confidential GPU) behind an attested no-log gateway the render enclave verifies in its measured image, trading a frontier model for an open-weights one ([the attested tier](./the-attested-tier.md)). The residual stands only where the frontier trade is chosen. - **The endpoint / analog hole** — plaintext must exist on a device for a human to use it; a compromised endpoint (keylogger, screenshot, a connector the client doesn't know about) is outside every software guarantee. Managed devices / air-gap raise this bar; software alone cannot. See [Air-gap](./deployment-options.md#air-gap-zero-egress). - **Metadata — IP hidden, shape not yet.** Your IP is hidden by **OHTTP** ("Hide my IP", Production) — no party sees both your IP and your rooms. Message sizes, timing, and access patterns remain visible to the network path until **client shaping** ships. - **Revocation offline** — attestation/verification chains verify offline against pinned roots; revocation freshness needs connectivity. - **Availability is not privacy** — the operator can refuse *service*; content-blindness is a claim about *reading*, not about guaranteeing access forever. --- ## How this reorganizes the doc set This page is the **entry point** for privacy claims. The existing docs slot under it: - **Maturity detail →** [Status](./status.md) · **Client-side proofs →** [Verify it yourself](./verify.md) · **First-party app config →** [The Spaces app: default configuration](./spaces-app-defaults.md) - **Mechanism deep-dives →** [The attested tier](./the-attested-tier.md), [The attested client](./client-architecture.md#the-attested-client), [The verifier](./client-architecture.md#the-verifier-extension), [The double blind](./trust-model.md#the-double-blind), [The power-user stack](./client-architecture.md#the-power-user-stack) - **Deployment overlays →** [Air-gap](./deployment-options.md#air-gap-zero-egress), [On-prem](./deployment-options.md#your-boundary), [Deployment options](./deployment-options.md) - **Feature docs** each state their **mode row from §6** at the top, instead of describing privacy ad-hoc. *Update rule:* when a mechanism changes state, update **§3** and [Status](./status.md) together; when a feature's disclosure changes, update its **§6 row**. --- # The Spaces app — default configuration This documents the out-of-the-box configuration of the **first-party Spaces app** — which of the platform's privacy mechanisms it turns on by default. It is **one app's configuration, not a platform property.** The platform *supports the full stack* (see [Privacy tiers](./privacy-tiers.md)); this page records only what Spaces, as shipped, enables. Any other app built on the runtime makes its own choices. --- ## What the Spaces app enables by default Mechanisms are defined in [Privacy tiers](./privacy-tiers.md); this table is only the first-party app's setting for each. | Mechanism | Spaces app default | Turn it on by | |---|---|---| | **Foundation** — admission gate, egress-lock, content-blind storage, signed + reproducible build, key confinement | **On** — structural, cannot be disabled | — | | **Server attestation** (Nitro enclave) | **Off** | "Run this Space inside the enclave" — opt-in per Space | | **Client verification** (external verifier) | **Off** | Install the Windows verifier extension, or run the verifier tool | | **Client attestation** (hardware) | **Off** — not shipped | — (Design) | | **OHTTP** (network-metadata) | **Off** by default — shipped | **"Hide my IP"** toggle (sticky, per device). Routes *all* app→AWS traffic through a pinned L1.5 frame + an attested relay (GCP Confidential Space) so no party sees both your IP and your rooms — see [Privacy tiers](./privacy-tiers.md) | | **Client shaping** | **Off** — not shipped | — (Design) | | **Notifications** (content-free push) | **Off** | Enable push on an installed PWA — opt-in per install | --- ## In mode terms So the first-party Spaces app ships at the **Baseline** mode ([Privacy tiers §5](./privacy-tiers.md)) — the always-on foundation, with the enclave, the verifier, and push each available **opt-in** per Space or per device. Turning any of them on moves that Space up the ladder (Verified → Attested → …), with the trades named in [Privacy tiers §7](./privacy-tiers.md). The **foundation is not a choice** — it is structural and holds for every Space regardless of these settings. What this page records is only which of the *optional* mechanisms are on by default, and Spaces leaves them off so the app stays frictionless while the stronger tiers remain one opt-in away. *Update rule:* when the Spaces app changes which mechanisms it enables by default, update this page only — the platform's capabilities live in [Privacy tiers](./privacy-tiers.md) and [Status](./status.md) and are unaffected. --- # Identity and admission Identity in Witbitz is not "there is an account in our database." A Space is **self-authenticated**: its link carries the public material needed to verify who belongs, and members hold the corresponding private credentials. The same definition works with **no server at all** (true peer-to-peer) and with a server that **verifies but cannot read**. It is inherited from an open, proven real-time engine — where it secures live P2P calls — and carried, mechanism for mechanism, to asynchronous, end-to-end-encrypted Spaces. This page is the whole subject: the quick reader model, then the definition and its primitive, the credential families, how membership is proved, **the owner rule** (who decides admission and at what tier), and — the part usually skipped — **removal, rotation and recovery**, marked precisely *shipped vs designed*. For a privacy product, overclaiming the lifecycle is worse than admitting a gap. > See also: [the double blind](./trust-model.md#the-double-blind) (why the platform can't read the room) and > [Verify it yourself](./verify.md) (reproduce the gate in your terminal). --- ## The quick model **Base membership: hold the room key.** Every Space has a room key `mk`. The client mints it and stores only a commitment on the server — `commit(mk) = base64url(sha256(mk))`. When a member acts, the render checks the presented key against the stored commitment: a right key proves the caller holds the link; a wrong key opens nothing. This proves *a* member is present — not *which* member. **Per-member identity: signed entries.** To attribute entries to a specific person, each member holds a signing key. The room's public invite key verifies the member grant; the member's public signing key verifies the entry. Because the author is inside the signed core, a rename or forged attribution fails verification. **Admission families.** A room composes any of these verifiers (e.g. a human gate *and* an agent allow-list): | Family | What it proves | |---|---| | **Key possession** | The caller holds the Space key. | | **Signed invite** | A creator or room authority granted this member access. | | **OIDC / Google** | The member authenticated as an allow-listed identity. | | **Agent key** | This agent key is allowed by the room. | | **Capability grant** | This participant may perceive or act in specific ways. | | **Owner policy** | The app owner mandated admission tiers for the room ([the owner rule](#the-owner-rule)). | **Gated Spaces.** On an email-gated Space the link alone is not enough: reads *and* writes both require an allow-listed sign-in — which is why the [verification page](./verify.md) starts with token-less `poll`/`turn` requests that return `403`. The allow-list lives in the sealed config, so the platform never reads the membership list. **Live vs designed, at a glance:** | Piece | Status | |---|---| | Key-possession membership | Shipped | | Solo signed identity | Live and verified | | Email-gated reads and writes | Live and checkable | | Agent-key admission · scoped agent reads | Deployed | | 2-of-2 per-member signed path · distinct invite links | Not yet | | Crypto-strong single-Space read revocation by epoch re-key | Implemented (fork-based re-key; built, wired, tested) | The rest of this page is the mechanism behind that summary. --- ## The definition A verified room has **no account server and no stored access-control list.** Instead: > **The link carries a *verifier*, never a secret. A member holds a *credential*. Anyone can check > credential-against-verifier using only the link's *public* material — so trust is rooted in the link, not in any > server.** Three consequences fall out, and together they are the whole model: 1. **The link is a capability.** Public verifier material — a room-invite *public* key, a creator *public* key, a name list, an OAuth client id — rides the link openly. Secrets — the room key, a member's *private* signing key — ride the **URL fragment**, which a browser never transmits. Witbitz adds one thing to the live-call link: the fragment also carries the **decryption key** for the room's content, so a Witbitz link literally holds *everything*. 2. **Verification is cryptographic, not a lookup.** "Is this a member?" is answered by verifying a signature against a public key from the link — never by asking a database *who* someone is. 3. **The verifier can be anyone.** Because the check needs only public material, the party that runs it is a free choice — a peer, a reader, or a certified function — without changing the credential. This is the hinge the rest turns on. **The hard constraint.** Nothing sensitive is persisted server-side. After a Space is created and its member links handed out, the **link is the entire authority**: it reconstructs the key, proves membership, and (optionally) signs entries. The server keeps only a **commitment** to the key and the **sealed** ledger — never the key, never plaintext. ## Membership = key possession (`mkCommit`) The base Space auth is **possession of the room key `mk`**, minted client-side and living in the link fragment — only its commitment is ever stored (`commit(mk) = base64url(sha256(mk))`, `agent/envelope.mjs`). On every mutating op the render checks `commit(presented mk) === the Space's stored mkCommit`: - a **right** key → you hold the link → you are a member → the turn runs; - a **wrong** key → `401 unauthorized`, sealed ledger untouched (a wrong key also decrypts nothing); - **first use** (no commitment yet) → the server returns `commitOut` so the deployment persists it, pinning the key model on first turn. **The key model (`k-of-n`)** governs *presence*, not per-member identity: `mk` can be split so presence is required to reconstruct it. **1-of-1 (solo)** — `mk` rides one link fragment (`#mk=…`). **2-of-2 (couple)** — a Shamir split where each partner's link carries one share (`#s=…`) and `mk` exists only when both combine; the creator seals `shareA` under an out-of-band code and registers it as the room's `invite`, the partner unseals with the code and combines. ## The primitive Every credential in the model is the same ECDSA P-256 token, and it is tiny (the live-call engine's invite token, reimplemented byte-for-byte in `agent/spaceEntrySig.mjs`): ``` token = base64url(JSON.stringify(payload)) + "." + base64url( ECDSA-P256-SHA256 sig over the first part ) ``` - **Bearer + unforgeable.** Minting needs the *private* key; verifying needs only the *public* key. Forging one is forging ECDSA, not guessing a short code. - **Room-bound + expiring.** Every payload carries `room` and `exp`, so a token cannot be replayed into another room or used forever. - **The link holds only the public half**, so *any* authority — even one that took over after the creator left — can verify without ever holding a private key. `signPayload` / `verifyPayload` are the shared floor; invite grants, agent assertions and host commands are all this token with a different payload and a wire tag, so they can never be confused for one another. ## The credential families A room can carry any of these verifiers; it composes them. - **Signed invites** (`inviteToken.ts`). The creator holds an invite keypair; each guest gets a grant `{name, room, exp}`; the link carries the invite **public** key (`gk`). A signed invite link, cryptographically — no server, no account. - **The signed manifest** (`roomManifest.ts`). The committed roster `{room, mode, exp, members?, domains?, agentKeys?}`, **signed by the creator key** and verified against the creator public key (`gm`) in the link, so every peer checks a joiner against the *same committed* roster. Can be passphrase-encrypted so a mere link-holder can't read *who* is allowed. - **Join-gate modes** (`joinGate.ts`). `open` · `names` · `code` (mailed secret, constant-time compared) · `email`/`google` (OIDC) · `invite`. All but `email` are serverless. - **OIDC + cert-binding** (`oidcVerify.ts` · `oidcBinding.ts` · `identityCert.ts`). "Sign in with Google," without a backend and without trusting the transport. `oidcVerify` is hand-rolled **RS256-only** ID-token verification — `alg:"none"`, HS\* (algorithm-confusion) and ES\* are rejected outright, the algorithm never taken from the token; the key is chosen by `kid` from the provider's JWKS, unknown `kid` fails closed. `oidcBinding` is the serverless heart: at sign-in the user sets the token's `nonce` to a hash of *their* WebRTC DTLS cert fingerprint, and every peer recomputes it from the cert it actually handshook with — so a token is valid only for the peer holding that cert's private key. `identityCert` pins one cert, private key never leaving the browser → non-transferable. Cert-binding composes with the emoji safety code (both read the same cert), so *"this is really Emma"* and *"there is no man-in-the-middle"* collapse into one guarantee. - **Agent keys** (`agentKey.ts`). An agent's identity *is* an ECDSA keypair; to enter it signs a **cert-bound assertion** `{k:"kbz-agent-key.v1", room, fp, iat}`, admitted iff the key is on the room's committed `agentKeys` and the assertion is bound to this connection and fresh. The room stores only the **public** key. - **Capabilities** (`capabilities.ts`). A per-participant **`Grant`** = what it may **perceive** (`read-chat`, `read-roster`, `read-media`, `see-screen`, `hear-audio`, `receive-directed`) and **act** (`send-chat`, `speak`, `act`). `defaultGrant('agent')` is least-privilege — an agent perceives the conversation but acts on nothing until granted. - **Verified host** (`hostKey.ts`). Admin is bound to a **password, not to who holds the room id**: the link commits the host public key (`gh`) and the private key sealed under a host password (`ghk`). It survives a coordinator migration — every peer already holds the committed key from its own link. ## Per-member identity in an async Space > **Status — per-participant verified identity is LIVE** (deployed + enforcement-proven in prod). Two admission paths > sign every turn and the deployed `/space` render verifies them, attributing each entry to a **verified** author > (forged / unsigned / non-allowed → `400 bad_attribution` / `403 not_authorized`). Reads are gated too: every content > op requires an allow-listed sign-in, so the link *alone* reveals nothing. Remaining: the **couple (2-of-2)** path is > still unsigned, and distinct per-member invite links aren't built. A live call attributes each message via the DTLS-bound connection; an async Space has no live connection, so each **ledger entry carries its own signature**. A member holds an ECDSA signing keypair; their grant is the live-call invite token — byte-identical to `inviteToken.signPayload` — with the superset payload `{ name, room, exp, spk }` where `spk` is the member's signing **public** key, signed by the room invite key and verified against the public `gk`. Each entry is signed over a canonical core `{ room, author, ts, kind, id, text }` — `author` is inside the signed core, so a rename is unforgeable. The email/Google path additionally binds a fresh, externally-issued OIDC identity to the signing key (`nonce = spkNonce(spk)`), so an entry proves "signed by the keyholder who authenticated as **this** account, now" — a leaked link is then no longer enough to post. ## One definition, three verifiers The verifier can be anyone — which is what lets the *same* credential model span three runtimes. Only **who runs the check** and **what fresh value the credential is bound to** change: | | **Live P2P call** | **Async P2P read** | **Async server verify** | |---|---|---|---| | Who verifies | a **peer** — no server | **any reader**, at read time | the **certified-ephemeral function** | | Membership | the join gate over presence | **hold the key** (`commit(mk) == mkCommit`) | same | | Freshness / anti-replay | the live **DTLS cert fingerprint** | the per-entry **signature** | a **server-issued challenge**, folded into the signed core | | Trust root | the link's public key | the link's public key | the link's public key **+** a certified deployment | | Content secrecy | peer-to-peer E2EE | sealed ledger, content-blind platform + owner | same — the double blind | **Async did not need a weaker model. It needed a different anchor for the same model** — the member's signing key (identity) plus a server-issued challenge (presence) in place of the DTLS fingerprint. **Where the server sits, precisely.** In async there is no peer to run the gate, so the **turn function** does — but it is a *certified, ephemeral, content-blind* verifier, not a trusted ACL store: it checks signatures against the link's public keys and a public JWKS (rooting no trust of its own); the allow-list it consults is sealed under `mk`, decrypted for one turn; an OIDC email it verifies is transient, never persisted; reads it serves an agent are sealed to that agent's key. The server *verifies* without being *trusted with content*. --- ## The owner rule {#the-owner-rule} > **Status: Implemented — built and tested, behavior-neutral, not yet enabled in production.** The full path is implemented and tested > end-to-end for both trust-root modes: **on-prem** (the owner pins one `OWNER_POLICY_KEY`) and **hosted multi-tenant** > (a tenant registry, `OWNER_REGISTRY=1`, resolves the trusted key *by the room's app*). Also built: the create-time > mandate, the render's integrity gate, tier-driven capabilities, client peer verification, tenant self-registration > (`op:'register-owner'`), and key rotation/revoke (`op:'rotate-owner'`). It stays inert until an owner opts in — keys > unset, rooms behave exactly as before. A Witbitz room is a door whose key is the link — great between equals, a problem the moment there's an **owner**. If a company runs an app on Witbitz, it has no way to say *"every room in my app is only for my people, and gold customers get more than free ones."* The owner rule turns **"the link is the authority"** into **"the owner is the authority,"** opt-in, without giving up content-blindness. **The rule: a signed App Policy.** The owner writes one small document and signs it with a key only they hold: ``` AppPolicy { app: "acme-portal" // which app this governs ownerKey: // the root of authority tiers: [ { name:"open", admit: anonymous, caps:{ read }, limits: low } { name:"member", admit: { issuer:"acme.okta.com", any:true }, caps:{ read, chat }, limits: mid } { name:"gold", admit: { issuer:"acme.okta.com", claim:{group:"gold"} },caps:{ read, chat, act, premium }, limits: high } ] roomTiers: { default:"member", admits:["open","member","gold"] } } → signed by ownerKey ``` The signature is the whole trick: nobody without `ownerKey` can produce a valid policy, so nobody can forge a *weaker* one. Admission stops being binary — it's a **ladder** from anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case. **Bound to a room at birth.** When a room is created through the owner's credential (hosted, the owner's tenant key; on-prem, its own create endpoint), `create` stamps the signed policy **sealed** into the room config (the render reads and enforces it) and a **cleartext marker** `{ app, ownerKeyId, policyHash }` on the public record (so the content-blind platform and keyless read paths know "this room is owner-gated" without reading anything). The owner's create path refuses to mint a room that doesn't carry the policy — the room is *born* gated. **Only the app can mint its rooms (room-genesis).** The signed policy is a reusable public artifact — anyone could copy it and stamp it on a room *they* create, standing up a look-alike to phish the owner's users. So stamping a policy at creation also requires a **room-auth**: a signature by the owner key *over this specific room* (the room id is in the signed core). Only the app owner holds the key, so only the app can mint "a genuine room of X's." **Tiers can't self-promote.** A member's tier comes only from a **signed** source: IdP claims (the owner's directory is the source of truth for who is gold), a signed capability grant (a room admin promoting a member), and the room's own tier (the ceiling). A tampered client can't forge the claim or the grant, and the **render** — not the client — resolves and enforces the tier. **Enforced in three independent places, no single point to bypass:** | When | Check | Where it runs | |---|---|---| | **Birth** | create refuses any owner-room without the signed policy + marker | the owner's create path (tenant-keyed / on-prem) | | **Writes** (every turn) | the render opens the sealed policy, verifies its signature against the pinned `ownerKey`, resolves the tier, gates each action; policy absent/invalid → refuse | the certified render | | **Blind reads** | the cleartext marker makes keyless reads refuse; keyed reads pass the same identity gate | the content-blind platform | | **Join** | the member's app verifies the room policy against its own pinned `ownerKey` and won't join an unrecognized room | every member's app (peer check) | Per-tier **limits** (rate / token / tool budget) reuse the platform's existing metering — so the ladder doubles as the owner's **pricing surface**: admission, capability, and monetization collapse into one signed table. **Registration — Witbitz federates; it doesn't run the directory.** A "registered user" is a record in the *owner's* identity provider, not a Witbitz account. Owner registration happens once (the trust root: `ownerKey`, the IdP issuer + JWKS as a [`SPACE_OIDC_ISSUERS`](./deployment-options.md) entry, and the claim → tier map); user registration happens in the owner's own directory (enterprise SSO / SCIM / invite — the tier is a group the owner assigns, so a user can't self-register as gold); device enrollment mints a signing key per device bound at sign-in. So **joining a room is authentication, not registration**, and revocation is symmetric — remove the user from the `gold` group and their next sign-in drops a tier, enforced on the very next turn. **Orthogonal to attestation.** This is the identity + capability axis; whether the client is *trustworthy code* is a separate axis ([the verified client](./client-architecture.md)). They compose — a tier's `admit` can additionally require an attested client — but identity-tiers work on the plain web with no attestation in sight. **The honest trade.** Requiring registration deliberately reintroduces a **central authority** — the owner's directory — into an otherwise account-free, link-based model. That's not a regression; it's exactly the control an owner asks for, and it's opt-in per app/room. The platform stays content-blind throughout — the policy is enforced in the certified render and by peers, never by a platform that reads the room. --- ## Lifecycle — removal, rotation, recovery {#member-lifecycle} The hard questions are never the initial key exchange. A bearer link is easy to copy, screenshot, sync, log, lose or leak — so these are the ones that matter. ### After a member is removed - **Writes — cut now (shipped).** A revoked member is on the Space's `revoked` list (sealed in the config); the turn op rejects their next turn `403` even with a valid credential. - **Reads — server cut shipped for email-gated Spaces; the crypto cut is designed.** Every read op checks the allow-list + `revoked` before the server decrypts, so a removed member's next read is `403` — immediately, no re-key. *Open Spaces have no read gate — the link reads.* The crypto-strong version (holding even if the server were bypassed) is an **epoch re-key** sealing new content to a fresh key for the remaining set — live in the Bridge, not yet ported to the single-Space ledger. **"But the link *is* the auth — how can you cut anyone?"** A link is a bearer credential: the removed member already extracted `mk` the first time they opened it, and no edit reaches into their browser to delete it. You cannot *retract* a held key — only *abandon* it. Revocation works because of the thin-client model: **the client never decrypts — the server does.** The server is a decryption oracle, and cutting someone means **denying the oracle**, not un-giving the key. (There's no "their slice" to strip — everyone holds the same `mk`; you remove them from the **policy**, not the key.) Dropping the member from the allow-list is a *complete, shipped* revocation for an ordinary private Space; an epoch re-key buys exactly one more thing — the cut holding **without trusting the platform**. ### History vs future Revocation cannot make someone un-see content they already read — true for every end-to-end system. A re-key protects the **future**, not the past. History a member already saw remains known to them; new content can be blocked by read gates now and by epoch re-key in the stronger model. For rooms with hard compartment boundaries, design epochs up front. ### Membership change is a fork Adding or removing a member is **one epoch operation**, not an edit to a member list. The room forks: a fresh room key and roster, the history carried across, and a sealed pointer left behind so present members follow automatically. The removed member keeps whatever they already had (that cannot be undone) but holds no key to anything after the fork, and no deny-notice has to reach every peer for the guarantee to hold. That delivery problem is what sank the earlier in-place "config update" and lease designs. Both the Bridge and single Spaces use epoch re-key on every membership change — single Spaces via `spaces/public/spaceFork.js` (built, wired to the Members UI, and tested in `agent/spaceFork.test.mjs`). Re-key is triggered by a membership change; absent one, a Space keeps a long-lived key (no scheduled rotation). ### Rotation and replacing a compromised identity Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. Per-member signing keys rotate independently: revoke the old grant, admit the new key. Replacing a compromised identity needs no rebuild — a member is a signing key bound by a grant, not the room itself: **revoke** the old grant (shipped), **admit** a fresh key, and **re-key** the epoch where a crypto-strong future read-cut is required (designed). The Space, its history and every other member are untouched. Same for an agent — its identity is its key, so a rotated key is a one-line allow-list change. ### Metadata that remains Honestly: **timing, sizes and coarse structure — never content or membership.** The platform stores the `mk`-commitment, the sealed config and the sealed ledger, and observes *when* turns and polls happen and *how big* the ciphertext is. It does not see the conversation, the members' names (sealed in the config), or — in invite mode — any real-world identity. In Google-sign-in mode it sees the presenter's email transiently, at verify time, and discards it. The irreducible leak is **timing correlation**, which no content encryption removes. --- ## Recovery {#recovery} Witbitz deliberately has **no operator recovery key.** If the platform could recover a Space, the platform could read a Space. Recovery must belong to the user or organization, not to Witbitz as operator. **The current rule.** If every copy of the room key is lost, the Space is lost. That is not a product flourish; it is the cost of the privacy claim — the production envelope is sealed to the room key, not to an operator, support, escrow, or beta recipient. Verify it with the sealed-store check: `POST {"op":"sealed","room":""}` → the recipients list should name the **room**, not the operator ([Verify it yourself](./verify.md)). **Recovery must be user-held.** Passkey (**shipped**) · backup code (**shipped**) · k-of-n social recovery (the same Shamir split the couple key uses — *not built*) · organization key policy (the customer controls it — *on-prem*). None require Witbitz to hold a readable recovery recipient. **The shipped vault.** One vault per account, and **either** enrolled method opens it: a random 32-byte **master secret** identifies and encrypts the vault; each method stores one **wrapper** (the master secret sealed to that method's own secret); restoring with any enrolled method opens its wrapper, recovers the same master secret, and opens the one backup. So a passkey and a backup code are not two backups to keep in sync — they are two doors into the same room. The server stays content-blind throughout: it stores opaque blobs at **derived** ids and never receives the code, the passkey secret, the vault key, or any plaintext. Wrappers are written *before* the data they unlock, so a half-finished enrolment can never leave a vault nobody can open. **The offline export.** Recovery that depends on the platform being reachable is not self-sovereign, so a Space can also be exported to a file you keep, alongside a standalone decryptor page that opens it with no server at all. The export is **opaque by construction**: an earlier version wrote each Space's `room`, `title` and message `count` in the clear next to its sealed ledger — unreadable ciphertext that nonetheless announced every conversation's name and size to anyone who opened it in a text editor. That metadata is now sealed under the same room key as the ledger it describes; the decryptor shows "Conversation N" until you load your keys file. Two things remain visible: **how many** conversations the bundle holds and **when** it was exported (hiding the count would mean padding with decoys — more cost than it buys for a personal backup). **Enterprise / on-prem recovery.** In an on-prem deployment the organization controls the runtime boundary — its own backup policy, IdP, secret storage, and recovery procedure. The privacy story changes from "trust Witbitz not to read" to "verify the image you run and control the boundary yourself" ([Deployment options](./deployment-options.md)). **What not to promise.** Witbitz *cannot* recover a lost Space on hosted production — by design. And don't hide the user burden: user-held recovery is safer than operator-held, but it still requires a recovery artifact, another device, a passkey sync provider, a social-recovery set, or an organizational process. --- ## Short links stay blind (`/shorten`) A room whose link holds the key must never have that key **stored** by the shortener, or the operator would hold it. So the shortener is **blind-safe**: `POST /shorten` refuses any target carrying key material (`k`/`key`/`mk`/`sk`/`secret`/`seed` in query or fragment); `GET /r/{code}` re-attaches the short link's own `#fragment` client-side. The shortener may store the room's base, never the key. ## Security properties - **Unforgeable** — every credential is an ECDSA P-256 signature; minting needs a private key. - **No cross-room, no replay** — `room` + `exp` in every payload, plus a per-connection fingerprint (live) or a single-use, TTL-bounded server challenge (async). - **No algorithm confusion** — OIDC verification is RS256-only; `alg:"none"`/HS\*/ES\* rejected; the verifier algorithm is never taken from the token. - **Fail-closed everywhere** — a malformed grant, unknown `kid`, missing signature or empty allow-list → *no access*. - **No server trust root for the credential** — a compromised or curious platform cannot mint membership, only (in the async-write locus) decline to run. - **Sealed policy** — the policy is sealed under `mk`; verified identities are transient. ## Status | Piece | Live P2P | Async | |---|---|---| | Key-possession membership (`mkCommit`) | n/a — presence gate | **shipped** | | k-of-n key model (solo / 2-of-2 Shamir) | n/a | **shipped** | | Signed-invite grants + per-entry signatures | shipped | **deployed + live-verified** (forged → `400`) | | Capabilities in the grant (perceive/act) | shipped | **deployed** | | OIDC / Google admission, allow-listed | shipped (cert-bound) | **LIVE — enforcement-proven** (unsigned / non-allowed write → `403`) | | Presence proof / anti-replay | shipped (cert-binding) | **deployed** (server-issued challenge) | | Agent admission by allow-list (`AgentEntry`) | shipped | **deployed** (same shape, live + async) | | Scoped read (least-privilege perceive) | shipped (mesh) | **deployed** (`view` + member-absent `fetchview`) | | Identity-gated **reads** (the link alone reveals nothing) | admission gates presence | **LIVE — enforcement-proven** (token-less `poll` → `403`) | | Owner rule (signed App Policy, tiers, room-genesis) | n/a | **Implemented — not yet enabled in prod** | | Blind-safe `/shorten` | n/a | **deployed** | | Remove a member — cut **writes** | shipped | **shipped** (gated Spaces) | | Remove a member — cut **reads** (server read-gate) | n/a | **shipped** for email-gated Spaces | | Remove a member — cut **reads** (epoch re-key, forward secrecy) | n/a | **shipped in the Bridge**; **designed** for the single-Space ledger | | Rotate a compromised member/agent key | shipped | **shipped** (identity + revoke) + **designed** (re-key for full read-cut) | | Device-loss recovery (user-held code / k-of-n social) | n/a | **passkey + code shipped**; social recovery **designed** — never operator-held | | Per-member identity — couple (2-of-2); distinct invite links | n/a | **not yet** (solo signed path live) | ## Code map | Concern | Live P2P | Async | |---|---|---| | Signing primitive (`payloadB64.sigB64`) | `src/core/inviteToken.ts` | `agent/spaceEntrySig.mjs` (byte-compatible) | | Signed invites / grants | `src/core/inviteToken.ts` | `agent/spaceEntrySig.mjs` + `spaces/public/entrySig.js` | | Signed roster / policy | `src/core/roomManifest.ts` | the **mk-sealed config** (`agent/spaceHandler.mjs`, `spaceService.mjs`) | | Join-gate modes | `src/core/joinGate.ts` | admission `open`/`invite`/`email` in `agent/spaceService.mjs` | | OIDC verify + binding | `oidcVerify.ts` · `oidcBinding.ts` · `identityCert.ts` | `agent/spaceOidc.mjs` (RS256/JWKS + `spkNonce`) | | Presence / anti-replay | cert fingerprint | `agent/spaceChallenge.mjs` | | Agent admission | `src/core/agentKey.ts` | `agent/spaceAgentKeys.mjs` | | Capabilities | `src/core/capabilities.ts` | `agent/spaceCaps.mjs` | | Scoped read | mesh perceive-gating (`mesh.ts`) | `agent/spaceScopedView.mjs` (`view`/`fetchview`) | | Verified host | `src/core/hostKey.ts` | `agent/delegatedAuthority.mjs` | | Room key mint / commit / at-rest seal | — | `agent/envelope.mjs` (`newRoomKey`, `commit`, `seal`/`open`) | | Recipient sealing (ECDH box) | — | `agent/envelope.mjs` (`sealTo`/`openBox`) | | Owner policy (sign / verify / register / rotate) | — | `agent/spaceHandler.mjs`, `tools/owner-policy.mjs` | | Link codec + k-of-n | — | `spaces/public/spaceConfig.js`, `shamir.js`, `invite.js` | | Epoch re-key + forward secrecy | — | `agent/bridgeMembership.mjs` | | Blind-safe short links | — | `control-plane/src/shortlink.mjs` | --- The through-line: **a room's authority lives in its link, and the check that enforces it can run wherever the room happens to be — on a peer, in a reader, or inside a function that verifies but cannot read.** > **Don't take our word for it → [Verify it yourself](./verify.md).** A token-less read/write to an email-gated Space > returns `403`; `curl -I` the app to read its browser-enforced egress allowlist; `POST {"op":"sealed"}` to read the > exact ciphertext the server stores — `recipients:["room"]` proves it's sealed to your key, not the operator's. --- # The verified client, in full Everything a browser runs for a Space is delivered from an origin, and an origin can be compromised — a popped hosting account, a bad deploy, a poisoned cache. The verified client closes that gap on **two orthogonal planes**, and a serious client needs both: - **Verification (integrity)** — *what code runs.* The browser refuses to run any byte that isn't the exact, platform-signed release. - **Confinement (custody)** — *who can touch the room key.* Key custody lives in an outer frame the app cannot read, so the browser's Same-Origin Policy — not the app's own discipline — is the wall. They compose: verification pins the loader so only approved code runs; confinement keeps the key out of that code's reach anyway. **Verify the prisoner and keep the cage.** From there this page climbs the ladder to the strongest tiers — a native [attested client](#the-attested-client), the [verifier extension](#the-verifier-extension) that forces verification on the real bytes, the [power-user stack](#the-power-user-stack) that removes Witbitz from the trust base, and the [certified app](#the-certified-app) that makes a *closed-source* app machine-auditable. > **Status.** The core chain — pinned key → signed cert → manifest → loader → `__vcImport`/`__vcFetch` → bundle + workers > + chunks + pinned assets — is **built and proven on staging** (`preview.witbitz-spaces.pages.dev`) **when forced by the > verifier extension / `?vc`** — the *verified client*, **Implemented**, not the default production boot. Its > extension-free, origin-served counterpart — [the pinned bundle](#the-pinned-bundle), a self-bootstrapping loader + a > signed client manifest that content-pins the shell's *imported module closure* — is **Design, not built**. The > confinement plane (vault sandwich + Kernel) is **live in production**. Where a piece is design or device-only it says so. ## The one problem it solves `script-src 'self'` — the normal same-origin policy — trusts **any** script the origin serves. That is exactly the wrong thing against a *compromised origin*: the evil script is same-origin, so `'self'` waves it through, and stripping the Subresource-Integrity attributes off the HTML disables the per-file checks. The verified client removes `'self'` and replaces "trust the origin" with "trust one small loader whose hash is pinned out-of-band, and let it check everything else." --- # Part 1 — Verification: what code runs ## The trust anchor and the chain Trust bottoms out at a **pinned platform public key** (P-256), published in [verify it yourself](./verify.md) and compiled into the loader and the verifier — never fetched from the app. From there, one signature covers the whole frontend: ```text PINNED platform key → signed /cert.json (frontendSha256, ES256) → sha256(/assets-manifest.json) → every file's hash ``` - **`/cert.json`** is a platform-signed commitment. Its `frontendSha256` is the SHA-256 of the served `/assets-manifest.json`; the signature is verified against the pinned key, and the cert is rejected if it embeds a *different* key (no key-swap). - **`/assets-manifest.json`** is a content-addressed map `{ path → sha256 }` of every served file. Because the cert signs its hash, the manifest — and every file hash in it — is trustworthy once the cert verifies. A compromised origin can serve an evil bundle, but it cannot forge a cert that verifies against the pinned key, so any swapped byte fails the chain and is refused. ## The loader — the small, stable, pinnable entry `spaces/public/space-loader.js` is the entry point. It is deliberately tiny and **stable across app releases** (it imports nothing versioned), so its hash is the one thing an extension has to pin. `space.html` loads it: ```html ``` Its exported `verifyRelease()` runs the chain above (pinned key → cert signature → `frontendSha256 == sha256(manifest)`) and returns run-or-exit. On a mismatch the app is **never imported** and the user sees a plain "couldn't verify this app" page instead of running unknown code. ## Two tiers Verification is opt-in per load, so the default path is byte-identical to a normal boot and a stray cert outage can never brick startup. | Tier | Trigger | What it guarantees | |---|---|---| | **A — verify-in-place** | `?vc=1` (or the verifier) | The served frontend matches the signed release (run-or-exit). The app still loads as normal ES modules under `script-src 'self'`. | | **B — loader-only CSP** | `?vcbundle=1` (or the verifier) | `script-src '' '' …` with **no `'self'`**. Evil same-origin JS cannot run at all. | Tier A answers "is this the right release?" Tier B additionally answers "can anything *else* run?" — no. The [verifier extension](#the-verifier-extension) is what *forces* either tier so the origin can't opt out. ## How Tier B runs a whole app under "no `'self'`" Under a loader-only CSP the browser won't load same-origin `