# 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 the open Kibitz 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 ciphertext at rest, 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/docs/llms-full.txt. ## Start here - [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). Read this first. ## Docs (deep dives, code-synced) - [What Witbitz is — the platform](https://witbitz.chat/docs/platform.md): the read-first overview — the idea, the runtime services, the three layers (interaction / platform / app), the two guarantees (double blind + human authority), exactly what's reused from Kibitz vs witbitz's own, the first app, and the app topologies. - [The attested tier](https://witbitz.chat/docs/the-attested-tier.md): **built and independently verifiable, NOT yet serving traffic** — the room key generated inside AWS Nitro hardware and bound into a hardware-signed attestation, so sealing to it is sealing to a measured image. Browser-side verification (pinned AWS Nitro root, own X.509 chain walk), PCR0 reproducible from published source, PCR8 derivable from the published signing certificate rather than taken on our word. Also states plainly what it does NOT buy: model egress, metadata, behaviour-vs-measurement, and the audit cost of a large image. - [The Verified Room](https://witbitz.chat/docs/room-link-auth.md): **the crown-jewel model** — one definition of *who is in a room and who said what* that works with **no server (true peer-to-peer)** AND with a **server that verifies but cannot read**. All of Kibitz's mechanism (signed invites, the signed manifest, join-gate modes, RS256 OIDC + DTLS cert-binding, agent-key admission, capabilities, verified host) and how the SAME definition applies across three verifiers — a peer (live call), any reader (async read), and a certified-ephemeral function (async write) — each with a mechanism-for-mechanism Witbitz async counterpart. The nonce-binding insight: bind the credential to the DTLS fingerprint (live), the signing key (async identity), or a server-issued challenge (async presence). Deeply technical; read this to understand Witbitz auth end to end. - [Verify it yourself](https://witbitz.chat/docs/verify.md): reproduce the enforcement in your own terminal — token-less `poll`/`turn` to an email-gated Space → `403 not_authorized`, an open Space → `200` (the control proving the 403 is the gate, not a bad key). Honestly scoped: this checks the **admission gate**, NOT content-blindness at rest or declared-program-in-use (those are the signed certificate + attested tier, designed not issued). The page is built to grow — the certificate/attestation checks land here as they ship. "Verifiable at the gate; trusted in use." - [The Space link is the auth](https://witbitz.chat/docs/room-link-auth.md): the membership + key-model + double-blind foundation the verified-room credentials sit on — key possession (`mkCommit`), the k-of-n key model, the E2EE ledger + decrypt-once render, per-member entry signatures compatible with the Kibitz room link, and blind-safe short links. (For the full authorization gate see The Verified Room, above.) - [The owner rule](https://witbitz.chat/docs/the-owner-rule.md): **BUILT (opt-in, behavior-neutral)** — how an APP OWNER gets authority over admission without breaking content-blindness, turning "the link is the authority" into "the owner is the authority." An owner-SIGNED App Policy (a ladder of tiers: open/member/gold, each = an admission test + capabilities + limits) is mandated onto every room at creation through a credential only the owner holds, and enforced in three independent places (the render verifies the signature + gates each turn; a cleartext marker guards the blind read paths; peers verify against a pinned owner key). Tier comes from SIGNED sources (IdP claims / grants) so no self-promotion; the ladder doubles as the pricing surface. Registration federates to the owner's directory (Witbitz runs no accounts). Orthogonal to [the attested client](https://witbitz.chat/docs/the-attested-client.md) (person vs code). - [Identity, lifecycle & recovery](https://witbitz.chat/docs/room-link-auth.md): the hard, honest questions a bearer-link model must answer — member removal (writes cut now; reads cut by epoch re-key), historical vs future decrypt, key rotation, device-loss recovery (user-held, never operator-held), replacing a compromised identity without rebuilding the Space, and exactly what metadata the platform can still see. Marks shipped vs designed throughout, and introduces the **Witbitz Companion** — a device-side runtime that moves keys off the copy-around URL into the device keystore, with user-side rotation/recovery + install-once notifications (roadmap). - [The Companion](https://witbitz.chat/docs/the-companion.md): an **optional** native runtime — demoted from pillar to convenience. The essentials it was meant to add already work with **no app**: content-free push on an installed PWA (reach-me), a hardware-backed **passkey** + user-held recovery (keys), and local decrypt in the verifiable client — with [Sealed Spaces](https://witbitz.chat/docs/the-double-blind.md) the path to the key never reaching the server. A Companion would still add more reliable notifications and a Secure-Enclave keystore; nothing on the roadmap depends on it shipping. - [The attested client](https://witbitz.chat/docs/the-attested-client.md): **server seam BUILT; native clients not** — the missing symmetric half of server attestation. You cannot attest browser code, so offer (opt-in) a native shell that bundles the SAME reproducible web bundle INSIDE a hardware-attested app (App Attest / Play Integrity-or-Keystore / TPM+WDAC), loaded LOCALLY not off the network (a network-loading WebView / Android TWA attests the shell but not the logic = theater). Attestation freshness-bound per action; "only attested clients may join" becomes a server-verified admission condition. Honest split: integrity attestable on all platforms, shipped-binary reproducibility Android-clean / iOS-partial / Windows-DIY. - [Async Spaces](https://witbitz.chat/docs/async-spaces.md): the agent as a function, not a server — the sealed ledger, the decrypt-once render, content-blind poll (etag over ciphertext), the ops, idle ~ $0. Live in prod. - [The double blind](https://witbitz.chat/docs/the-double-blind.md): verifiable privacy — ciphertext at rest (platform blind) + a backendless static app (owner blind) + the certify-the-footprint north-star; shipped vs designed. - [The live call](https://witbitz.chat/docs/the-live-call.md): the OTHER trust model — WebRTC DTLS-SRTP between browsers, relay-by-default here (FORCE_RELAY, so the relay sees metadata but never content), signalling as the one attackable party, and the safety code that would verify it — currently not exposed in Witbitz Call's UI. Read this before conflating a call with a Space. - [Delegated authority](https://witbitz.chat/docs/delegated-authority.md): humans keep the final say — propose, approve, execute + a sealed append-only audit; the invariant that no action runs without an approval. - [Enterprise & on-prem](https://witbitz.chat/docs/on-prem.md): run the ENTIRE runtime in the customer's own boundary, off our cloud — every external seam is one env switch (model / object store / records / secrets / IdP admission), defaulting to hosted behavior when unset, so it's the same reproducible image. Two tiers (VPC + air-gap); `AIRGAP=1` makes it zero-egress (CSP names no external host, tools refuse) and the signed cert's meaning inverts to supply-chain proof the customer verifies on their own hardware. - [Platform API — the /v1 surface](https://witbitz.chat/docs/api-reference.md): tenant-keyed JSON API — auth (secret/publishable keys + capability tokens), the envelope, idempotency, and the surface (agents, collections, items, artifacts, sessions, credits, tenants). The OpenAPI is canonical. - [The Bridge](https://witbitz.chat/docs/the-bridge.md): governed collaboration between sovereign Spaces (the cross-org frontier) — the three-space model, the five invariants, key epochs, the live ops, native-or-external parties. Early protocol preview. ## API - [Platform API — OpenAPI (production)](https://api.witbitz.chat/v1/openapi.json) · [full design spec](https://api.witbitz.chat/v1/openapi.full.json) · [discovery](https://api.witbitz.chat/v1/): tenant-keyed, JSON over HTTPS. Agents, collections, sessions, wallets, ratings; a keyless render page for published content. Invite-only during private beta. - [The Bridge protocol — OpenAPI](https://witbitz.chat/docs/bridge.openapi.yaml) · [normative spec](https://witbitz.chat/docs/bridge-protocol.md): governed collaboration between sovereign Spaces (the forward-looking, cross-org frontier) — a shared Space N parties join, party-signed + content-blind, verified against per-party keys. Early protocol preview. ## Try it - [The Spaces app (demo)](https://witbitz-spaces.pages.dev): a multiplayer ChatGPT — a shared room where people and an AI collaborate, with tools that render as live widgets and a room that persists. No install, no account. ## The engine (Kibitz) Witbitz runs on the open Kibitz engine. The **live call IS Kibitz** — that path uses the deep-dives below by *running the engine*. The **async Space** is witbitz's own (turn-based, content-blind HTTP); it ports only the Kibitz **signed-invite** identity format (see the link-auth doc above), NOT the OIDC/cert-binding path. These Kibitz docs are canonical for the engine — witbitz does not duplicate them: - [Kibitz map for LLMs](https://kibitz.chat/llms.txt) · [everything in one fetch](https://kibitz.chat/llms-full.txt) - [Architecture](https://kibitz.chat/docs/architecture) — the room model, the transport planes, the composable engine. - [Threat model](https://kibitz.chat/docs/threat-model) — who can see what; the agent surface. - [Verification](https://kibitz.chat/docs/verification) — "the link is the verifier": the join-gate methods (the source we ported for async). - [Cert-binding](https://kibitz.chat/docs/cert-binding) — how an OIDC token is bound to the DTLS cert (the LIVE-only mechanism async does not use). - [Agent protocol](https://kibitz.chat/docs/agent-protocol) · [Agent platform](https://kibitz.chat/docs/agent-platform) — how an AI agent perceives + acts in a call, and the capability/consent model. - [Offline mode](https://kibitz.chat/docs/offline-mode) — same-Wi-Fi rooms via a LAN hub. ## Contact - hello@witbitz.chat — API access (private beta), general, and security. --- # What Witbitz Is — the platform Witbitz 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. Read this first; the other docs go deep on each piece. > One line: *a runtime for people and their AI agents to collaborate, where privacy is verifiable and humans keep > the final authority.* --- ## 1. The idea The internet was built for **users and applications**. The next generation needs a runtime where **people, apps, and autonomous AI agents** collaborate — but safely, with permissions that are explicit and revocable, and with a human always able to say no. Today's AI ecosystem is siloing: every vendor wants your users, agents, memory, and work inside its own platform, on a backend it can read. Witbitz is the sovereign alternative — your Space holds your people, agents, and data, and **nothing leaves it unless you allow it**. ## 2. What the runtime provides A collaborative app needs the same primitives over and over; Witbitz provides them so the app can be a thin static page: - **identity** — who a participant is (see [the link is the auth](./room-link-auth.md)) - **permissions** — who may do what (per-member capabilities; delegated, revocable authority) - **agents** — hosting + running the AI that lives *inside* the app - **storage** — the sealed, content-blind ledger + documents - **payments** — metering + wallets for agent compute - **secure execution** — the decrypt-once render where the AI acts, then drops the key ## 3. The three layers Witbitz is three layers — this is its architecture, not a product stack: 1. **Interaction** — how humans and AI meet. Today: **live calls** and **async Spaces** (more modes to come). 2. **The platform** — a general-purpose **app backend**: the whole app flow, plus the AI that runs *inside* the app. Exposed as the tenant-keyed `/v1` API ([API reference](./api-reference.md)). 3. **The app** — a static page with **no backend of its own**, connecting *only* to Witbitz. To be precise: this is **not** "no backend" — **Witbitz *is* the backend** (layer 2: identity, storage, agents, execution, permissions, billing). The proposition is narrower and more honest: you trade an *arbitrary, app-specific* backend for a **single, constrained, inspectable platform** one. That's what makes an app's privacy **verifiable** — there is exactly **one** trust surface and it is auditable, not that centralized infrastructure or trust dependency went away. Everything happens in a **Space**: a self-authenticated room whose identity, members, and keys live **in the link**, not a central account server ([room-link-auth](./room-link-auth.md)). ## 4. The two guarantees (why it's safe) - **Verifiable privacy — the double blind.** A Space's data is sealed **ciphertext at rest** under a key the platform never holds. As long as the compute runs the **declared** program, the operator is blind to the data and activity — **and so is the app's own owner**. Neither can read the users' content. Because the app is a backendless static page talking only to the audited Witbitz API, its whole data footprint is *declared* and its privacy is *checkable*, not promised. ([the double blind](./the-double-blind.md)). - **Human authority.** Agents never own authority — they receive it. An agent **proposes** an action, the runtime evaluates policy, an authorized human **approves or edits**, then it **executes** — explicit, limited, revocable, audited. ([delegated authority](./delegated-authority.md)). ## 5. Built on Kibitz (what's reused, precisely) Witbitz runs on the open [Kibitz](https://kibitz.chat) engine, and the relationship is exact — worth stating so nothing is overclaimed: - **The live call *is* Kibitz** (a pinned dependency, rebranded). That path gets Kibitz's full peer-to-peer gate — account-free rooms, the join gate, **cert-bound OIDC**, end-to-end-encrypted media — by *running the engine*. - **The async Space is witbitz's own.** It is a turn-based, content-blind HTTP service (no WebRTC, no OIDC, no cert-binding). The *only* thing it borrows from Kibitz is the **invite-token wire format**, reimplemented byte-compatibly for per-member identity ([room-link-auth §4](./room-link-auth.md)). So: for the **engine** (calls, mesh, cert-binding, the agent-in-a-call protocol), the canonical docs are Kibitz's — see the "engine" links in [`/llms.txt`](../llms.txt). For **witbitz's own layer** (async Spaces, the platform API, the double blind, the Bridge), these docs are canonical. ## 6. The first app: Spaces The first app built on Witbitz is **Spaces** — a multiplayer ChatGPT: a shared room where people and an AI collaborate together, with tools that render as live widgets and a room that persists. Try it: [witbitz-spaces.pages.dev](https://witbitz-spaces.pages.dev). ## 7. What you can build — the topologies Spaces connect in different shapes; the valuable apps are the ones where the shape *is* the product: 1. **One governed room** — an org's AI serves a person (tools + governance; the org can't read individual sessions). Customer service, a bank advisor, telehealth intake. 2. **Pro + AI with a private back room** — both serve a client, and confer privately. Teacher + AI, doctor + scribe, lawyer + paralegal. 3. **Two sovereign sides — the Bridge** — each side preps privately, they meet in a shared room. Negotiation, M&A, procurement, mediation. ([the Bridge](./the-bridge.md)). 4. **Hub & spoke** — a person (or case) at the center, several orgs bridge in. Care coordination, eldercare. 5. **Many players** — the Spaces app. Group planning, a family council, community rooms. 6. **A traveling personal agent** — your Space + agent joins others with scoped permissions. Personal assistant, shopping concierge. ## 8. Access The `/v1` platform API is invite-only during private beta (tenant keys). The Bridge is an early protocol preview. Email **hello@witbitz.chat**. ## 9. Where to go next - [The Space link is the auth](./room-link-auth.md) — membership, keys, per-member identity, the double blind (deep). - [The Verified Room](./room-link-auth.md) — one definition for live P2P, async reads, and server-verified writes (crown-jewel). - [async Spaces](./async-spaces.md) · [the double blind](./the-double-blind.md) · [identity lifecycle](./room-link-auth.md) · [delegated authority](./delegated-authority.md) · [the Bridge](./the-bridge.md) · [API reference](./api-reference.md) - The engine (Kibitz) deep-dives — linked from [`/llms.txt`](../llms.txt). --- # 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](./room-link-auth.md) (the model you're checking) · [the Space link is the auth](./room-link-auth.md) (how a real member passes) · [the double blind](./the-double-blind.md) (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 platform is **content-blind at rest** — it stores only ciphertext, sealed to *your* key, not the operator's | ✅ **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) | 🔶 **not yet on your traffic** — the attested tier is [built and verifiable](./the-attested-tier.md), but no production Space runs inside an enclave yet | 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 is content-blind at rest (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](./the-double-blind.md)). That tier is now **built and independently verifiable** — the room key is generated inside AWS Nitro hardware, the image measurement is reproducible from published source, and the whole check runs in your browser ([the attested tier](./the-attested-tier.md)) — but **no production Space runs inside an enclave today**, so it is a demonstrated capability rather than a property of the room you are using. When that changes, `/cert.json` will carry an `enclave` block and a Test 8 will appear on this page. 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 and checkable, just not yet switched on.** --- ## 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: **→ [github.com/kibitz-chat/witbitz-render](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 | grep -oE 'https[^"]+/space') 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](./the-double-blind.md)) — now shipped and enforced. You are reading the app's true data footprint off ground truth. (What it does **not** yet prove: that the *server-side* render binary is the published one — that's the build-hash + attested tier, still pending. This test bounds the **front-end**; the gate tests bound **access**; the render remains trusted in use.) ## Test 5 — content-blind at rest: the store holds only ciphertext, sealed to YOUR key 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 at rest, 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). **Built and independently verifiable — but not yet serving traffic**: 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 checkable on a demonstration enclave, and remains switched off for production Spaces. --- ## 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](./room-link-auth.md). This is the async analog of Kibitz 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). **Built and demonstrated on real AWS Nitro hardware — not yet operated on production traffic** ([the attested tier](./the-attested-tier.md)); the strongest rung, and the north-star. This page grew **"verify the gate" → "footprint" → "store" → "the build."** The gate is live (Tests 1–3), the footprint is live (Test 4), content-blindness at rest is live (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. When it ships, its check lands **here**. --- # The attested tier — "unobservable in use" > **Status: built and independently verifiable. NOT what serves your traffic today.** > Every measurement below was taken on real AWS Nitro hardware and every check is one you can run yourself. > But no production Space runs inside an enclave right now. When one does, `/cert.json` will carry an > `enclave` block and this line will say so. Until then, treat this as a demonstrated capability, not a > deployed property — and see [Verify It Yourself](./verify.md) for what *is* live. Everything else on this site proves things about code we **publish**: the app's egress is locked, the store holds only ciphertext, the deployed render is a reproducible build of source you can read. All of it stops at one wall. **None of it proves what the machine actually loaded.** Between the ciphertext arriving and the answer going back, plaintext exists in memory on a server we operate. Read [Verify It Yourself](./verify.md) closely and it says so: the one thing you cannot check is that the render is *unobservable in use*. This is the tier that closes that. ## What changes Your room key already travels sealed to a service public key rather than in the clear (`spaces/public/mkseal.js`). That module was written for this moment, and said so in its own header: *today the private half lives in the service; tomorrow it can live only inside an attested enclave, and nothing on this side changes.* In the attested tier, that recipient keypair is **generated inside an AWS Nitro enclave**, at boot, and never written or exported. Its public half is bound into a hardware-signed attestation document. Verify the document, and you are sealing your room key to a key that provably exists only inside a specific, measured image. "We cannot read your room" stops being a policy and becomes a property. ## The four things you can check Nothing here asks you to trust us, and none of it requires our cooperation beyond us publishing what we already do. **1. The document is genuine AWS hardware.** A Nitro attestation document is signed by a key in hardware the operator cannot reach. Our verifier (`spaces/public/nitroVerify.js`) checks the COSE signature and walks the certificate chain to the **AWS Nitro root G1, pinned in the source** — the document carries its own copy of that root and we discard it, because a verifier that trusts the anchor its input supplied is not verifying anything. It runs in your browser. WebCrypto cannot walk an X.509 chain, so the chain walker is written out in `spaces/public/x509.js` — about 180 lines, ECDSA only. **2. The image is the one you can rebuild.** PCR0 is a SHA-384 measurement of the running image. `infra/enclave/build-eif.sh` reproduces it from published source. Four things are pinned because each is an input: the base image by digest, `SOURCE_DATE_EPOCH`, buildkit by digest, and the nitro-cli version. This is the part most projects skip. A measurement you cannot reproduce only tells you the image hasn't changed since *we* built it — which is the "trust us" the whole ladder exists to escape. **3. PCR8, from the certificate rather than our word for it.** A signed image also reports PCR8, a measurement of the signing certificate. We publish **the certificate**, not just the number, so you derive PCR8 yourself: `SHA-384(48 zero bytes ‖ SHA-384(certificate DER))`. Our verifier prefers the value it computed and **rejects a cert.json whose stated PCR8 contradicts its own certificate**. A dishonest number is caught by arithmetic. *(AWS's own documentation describes PCR8 differently — as a hash of the certificate's "fingerprint" — which does not match their code and yields a different value. We follow the code, and confirmed it against real hardware.)* **4. Liveness, if you ask for it.** An attestation document with no challenge proves an enclave *existed*, not that it is the one answering you. Send a nonce; it comes back inside the signed document or the check fails. Our verifier reports `fresh: false` when no nonce was supplied, so a stale proof can never be presented as a live one. ## What it still does not buy This section is the point of the page. A security claim that only lists what it covers is marketing. - **Model egress is untouched.** The enclave establishes TLS itself, so neither we nor the parent instance can read a model call — but the model vendor receives your plaintext, because that is what the room is *for*. Attestation protects the key and the code. It cannot make a remote API stop seeing its input. - **PCR0 proves the image, not the behaviour.** It proves the running code is the code you can rebuild. Whether that code does what we say is what reading it is for; reproducibility makes reading it worthwhile, not unnecessary. - **Metadata remains.** Which room is active and when, message sizes and timing — an enclave changes none of it. - **The image is large.** Running the existing pipeline means an interpreter, a libc and ~86 MB of dependencies, all measured. The measurement is exactly as sound; the *audit* is a much bigger job than reading two files. A minimal variant exists for cases where the smaller claim matters more than the existing code. - **Availability is still ours to break.** The parent instance can refuse to forward your request. It cannot read it. ## Measured, not asserted On real hardware, 2026-07-29 — and the last row is the one that matters, because it is the product rather than a component of it: | check | result | |---|---| | attestation verified against the pinned AWS root, with our own nonce | ok — PCRs 0/1/2/8, `fresh: true` | | PCR0 vs an independent rebuild from published source | match | | PCR8 vs the published signing certificate | match | | room key sealed by the unmodified client, opened inside the enclave | opened | | the enclave asked to hand the key back | it returns `HMAC(key, challenge)`, never the key | | a sealed blob replayed as a different operation | refused | | one ciphertext bit flipped | refused | | verification in a browser | 1.8–22 ms | | **the full agent — model, tools, Places, map widget — answering in a real room** | **works** | That last line is not a reduced demo. It is the deployed handler, unmodified, with its tools, reached from a phone: the attestation verified in the browser, the room key sealed to a key born inside measured hardware, the reply generated in the enclave, sealed, stored on a parent that cannot open it, and rendered. The verifier is also exercised against genuine attestation documents from unrelated production enclaves, so it is known to handle real AWS output and not merely our own. ## What is left Everything above is demonstrated, and **none of it is switched on**. What remains is operational rather than unknown: - **Routing and lifecycle.** One instance, started by hand. Production means a fleet behind the API, health checks, and measurements rolled into `/cert.json` on every deploy. - **Audit surface.** Running the existing pipeline means an image of ~250 MB rather than the 5.6 MB minimal variant. The measurement is exactly as sound; reading it is a much bigger job. Two things that *were* on this list are now done, and both are the kind of claim worth stating precisely: **Secrets are released only to a measured enclave.** Credentials are sealed under a KMS key that refuses to decrypt unless the request carries an attestation matching our signing identity. An account administrator is denied. The host holding the IAM role, using the very credentials the enclave uses, is denied. The enclave is allowed. The parent hands over ciphertext and gets back a list of *names* — never a value. **Storage is durable and still unreadable.** Sealed blobs live in S3. Read back with account-admin credentials, a conversation is an IV, a ciphertext, and one recipient: the room. Not the operator. When a Space really runs in an enclave, `/cert.json` will carry an `enclave` block naming the PCRs, and a Test 8 will appear on the verify page. Until then this page describes a capability, not a property of your room. Read next: [Verify It Yourself](./verify.md) for what is live today, and [The double blind](./the-double-blind.md) for the model this completes. --- # The attested client — knowing the code that runs on the user's side > **Status: the server-side seam is BUILT (opt-in, behavior-neutral); the native clients are not.** A room's > owner-signed policy can carry `requireAttestation`, and the render verifies a freshness-bound platform attestation > against it — that half is implemented and tested (with a mock verifier), bound to the member's signing key so it > can't be replayed. What still needs the *native* side: the reproducible native shells and the real App Attest / > Play Integrity / Keystore / TPM verifiers (today they're stubbed and fail-closed, so a `requireAttestation` room > refuses every turn — the seam is ready for the verifiers to drop in). This is the missing symmetric half of the > *server* attestation the rest of this site already does (reproducible build, egress-lock, signed `/cert.json`). Every proof on this site is about code we run on our side. The client — the page in the user's browser — has been trusted on faith. And that faith is misplaced by construction: **you cannot verify the code a browser is running.** The user owns the runtime; any integrity check the page performs on itself can be hooked, patched, or spoofed, and nothing stops the user from ignoring the app entirely and calling the API with their own client. So a modified client can interact with a room in ways the audited app never would. The response is *not* to try to attest the browser — that's unwinnable. It's to offer, opt-in, a client whose code **can** be attested, without giving up the web app. ## The idea: a thin native shell around the same reproducible bundle The app you already ship is a static, egress-locked, reproducibly-built web bundle. The attested client is that exact bundle, wrapped in a thin native shell whose only jobs are narrow and auditable: 1. **present a hardware attestation** — prove a genuine, unmodified build of the app is running on a genuine device; 2. **hold keys in hardware** — the Secure Enclave / StrongBox / TPM, so the room key never sits in JS; 3. **load the bundle locally** into a locked web view; 4. **freshness-bind** the attestation to each action. You don't rewrite the app natively. There is **one source of truth** across web and attested-native, the privacy model is preserved — in fact strengthened, since keys move into hardware — and the audit surface is a thin generic shell plus the web code reviewers already read. It is the [Companion](https://docs.witbitz.chat/docs/the-companion.md) promoted from a convenience into a trust primitive. ## The one rule that makes it real, not theater **The web bundle must ship inside the attested binary and load locally — never off the network.** - **Bundled, loaded from a local scheme:** the app's signature covers its embedded assets, so an attestation of "genuine, unmodified app" *includes the bundle*. Tamper with the bundle and the signature no longer matches — the attestation fails. The attested thing and the executed logic are the same thing. ✅ - **A web view pointed at a live URL (this includes Android Trusted Web Activities):** you attest the *shell*, which then runs whatever the network hands it — outside the attestation, and hookable at runtime. That is **worse than a plain browser**, because it launders trust it never earned. ❌ The Trusted Web Activity is the tempting trap here precisely because it is the easiest to build. Two locks keep the bundled version honest: the native↔JS bridge must be minimal and audited (no general script-injection surface), and the existing **egress-lock CSP** already forbids the bundle from pulling in remote script — so a bundled page can't quietly turn itself back into a network loader. And the attestation must bind to **every action**, not once at admission — otherwise a member attests with the genuine app, then switches to a patched client for the writes that follow. The room's existing freshness challenge is the hook: each signed entry carries a fresh, attestation-bound value. ## Platform support — and an honest split The attested client is a web-view shell on each platform. The strength of the guarantee differs, and it's worth being exact about two *distinct* properties: **integrity** ("the running bundle is unmodified") and **reproducibility** ("the shipped binary rebuilds from published source"). | Platform | Shell | Attestation | Keys | Notes | |---|---|---|---|---| | **Android** | Android WebView | Play Integrity, or **hardware Keystore attestation** (X.509 rooted in Google, carries app-signing digest + Verified Boot) | StrongBox / TEE | **Cleanest full chain.** Self-distributed + reproducible APK + Keystore attestation gives source → binary → attested. (Play Integrity is easier but ties you to Play distribution, which fights reproducibility — a real tradeoff.) | | **iOS / iPadOS / macOS** | WKWebView | **App Attest** (hardware-backed, iOS 14+ / macOS 11+) | Secure Enclave | **Integrity strong; reproducibility partial.** Apple re-signs and encrypts the shipped binary, so you can't rebuild the App-Store artifact from source — you lean on a transparency-logged build pipeline for provenance. | | **Windows** | WebView2 | **Assembled** — TPM 2.0 measured boot + Azure Attestation (device) · TPM key attestation (key) · Authenticode/MSIX + optional **WDAC** (app) | TPM 2.0 | No single "App Attest for Win32"; you compose device + key + binary-measurement attestation. **Strongest on a managed enterprise fleet** (MDM + WDAC), which is exactly the enterprise buyer. | So **integrity of the running bundle is attestable on every platform**; end-to-end **reproducibility of the shipped binary** is Android-clean, iOS-partial, and Windows-DIY. State that split plainly rather than implying one uniform guarantee. ## The egress lock, generalized Today the egress lock **is** the CSP header. With attested native clients it splits into two things a verifier keeps separate: - **The policy** — one canonical, platform-neutral egress allowlist, signed into `/cert.json`: the *room-data* path goes only to the declared origin, and the shell additionally reaches only a small, fixed set of platform-service endpoints (the attestation provider, push). A verifier reasons about **one** declaration. - **The enforcement** — the per-platform binding of that policy. The web CSP is just its *web* binding: | Platform | Enforces the allowlist via | How a skeptic checks it | |---|---|---| | **Web** | the CSP header (`connect-src …`) | **read the live header** — self-verifiable, trust nothing | | **iOS / macOS** | CSP inside WKWebView + App Transport Security; a Network Extension / per-app VPN under MDM | reproduce + attest the binary and its `Info.plist` (ATS is TLS policy, not a host allowlist) | | **Android** | CSP inside WebView + `network_security_config.xml`; always-on VPN under MDM | reproduce + attest the APK and its network config | | **Windows** | CSP inside WebView2 + AppContainer network capabilities (MSIX) + Firewall / WDAC | attest the binary + its manifest (capabilities are coarse — internet vs private, not per-host) | **The honest inversion.** Every native OS control here is *coarser* than `connect-src` — none is a clean per-host allowlist you can read off a live response. So on native, the fine-grained guarantee leans more on the **reproducible, attested binary** ("this app provably contains no network code beyond the declared endpoints") than on a readable OS policy, and the verification *method* flips: on the web you **read a header**; on native you **reproduce and attest the binary** and inspect its (coarser) declared config. The web is not the weak link in the egress story — it is the platform with the *cleanest, self-verifiable* lock. Native buys code attestation and partly pays it back in egress-verifiability, making up the difference with the reproducible build. **Purpose-scoping is mandatory.** The shell needs egress the web bundle doesn't — the attestation call to Apple / Google / Microsoft, and push. So the canonical allowlist scopes by purpose: the *room-data* path stays same-origin and locked exactly as today; the shell adds only a small, fixed, **declared** set of platform endpoints. That is a mild new trust surface (the platform attestor sees metadata), and it is stated, not hidden — the same honesty discipline as the rest of the cert. ## How a room requires it "Only attested clients may join" is not a client-asserted flag — it is an **admission condition set by the room/app owner and verified on the server**, in the same slot that carries "only my registered users" (see [The Space link is the auth](https://docs.witbitz.chat/docs/room-link-auth.md)). At join and on each write, the server checks the platform attestation, bound to the member's signing key. Other members can be shown that a participant is on an attested client — the assurance is visible peer-to-peer, not just to the server. Admission and client-integrity become one policy: `{ authenticated identity } AND { attested client }`. ## What it buys — and what it doesn't It **shrinks** the hardest residual: content injection between members. If only the audited, unmodified app can write, and that app is audited to seal only well-formed payloads, arbitrary injection is off the table for compliant members. It does **not** make the client omnipotent-proof. A jailbroken or rooted device (attestation fights this, but it's an arms race), a vulnerability in the genuine app, and the fact that even an attested client still processes untrusted content *from others* all remain — so receiver hardening (XSS-safe rendering, sandboxed widgets, validated tool inputs) still matters; it just matters less. And because requiring an install trades away the open-a-link reach that makes the web app spread, this is **opt-in and additive**: web rooms keep the untrusted-client architecture (accountable identity + server-side enforcement); attested-native rooms get this stronger tier on top. ## Why it fits This is the natural apex of the attestation story the [attested tier](https://docs.witbitz.chat/docs/the-attested-tier.md) started on the server side. Today the render is attested and reproducible while the client is trusted on faith — an asymmetry. Closing it with a bundled, attested, hardware-keyed client makes the system attestable **end to end** for the rooms that ask for it, without abandoning the static-web-page model that makes any of it verifiable in the first place. --- # The Verified Room — identity, admission and lifecycle One definition of **who is in a room and who said what** — and the reason it is interesting: **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 the open **Kibitz** engine, where it secures live peer-to-peer calls, and Witbitz has carried it — mechanism for mechanism — to **asynchronous, end-to-end-encrypted Spaces**, where a certified, content-blind function does the verifying when no peer is present. This page is the whole subject: the definition and its primitive, the credential families, how membership is proved, and — the part that is usually skipped — **removal, rotation and recovery**, marked precisely *shipped vs designed*, because for a privacy product overclaiming the lifecycle is worse than admitting a gap. > A Witbitz **Space** is a self-authenticated entity: its identity, membership and keys live **in the link**, not in > a central account server. The **live call *is* Kibitz** (a pinned dependency, rebranded), so that path gets > Kibitz's full gate by *running the engine*. The **async Space** carried that gate over mechanism for mechanism — > the credential *formats* are Kibitz's; only the *verifier* changes. > See also: [the double blind](./the-double-blind.md) (why the platform can't read the room), > [the owner rule](./the-owner-rule.md) (who decides admission, and at what tier), and > [Verify it yourself](./verify.md) (reproduce the gate in your terminal). --- ## 1. 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 Kibitz's 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 on which the rest of the page turns. **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. ## 2. Membership = key possession (`mkCommit`) The base Space auth is **possession of the room key `mk`**. `mk` is minted client-side and lives in the link fragment; the client reconstructs it locally and sends it with each turn/poll — **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`, and the sealed ledger is 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. So the check is **cryptographic, not an identity lookup**. Key possession proves *a* member is speaking; **which** member is established by the per-member signature (§5). **The key model (`k-of-n`).** `mk` can be split so presence is required to reconstruct it: - **1-of-1 (solo)** — `mk` rides one link fragment (`#mk=…`), or a random pad shape so a stored record can't tell solo from couple. - **2-of-2 (couple)** — a Shamir split: each partner's link carries one **share** (`#s=…`); `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 server stores only that ciphertext); the partner unseals it with the code and combines with their own share. k-of-n is about *presence* — who must be there to open the Space — not per-member identity. ## 3. The primitive Every credential in the model is the same ECDSA P-256 token, and it is tiny (`kibitz/src/core/inviteToken.ts`, 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. ## 4. The credential families The link can carry any of these verifiers; a room composes them (e.g. a human gate *and* an agent allow-list). **4.1 Signed invites** (`inviteToken.ts`). The creator holds an invite keypair; each guest gets a grant token `{name, room, exp}`; the link carries the invite **public** key (`gk`). A signed invite link, cryptographically — no server, no account. **4.2 The signed manifest** (`roomManifest.ts`). The room's 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, and the creator's signing key is discarded after minting. A manifest can be **passphrase-encrypted**, so a mere link-holder can't even read *who* is allowed. **4.3 Join-gate modes** (`joinGate.ts`). `open` · `names` · `code` (mailed secret, constant-time compared) · `email`/`google` (OIDC — §4.4) · `invite`. All but `email` are **serverless**. **4.4 OIDC + cert-binding** (`oidcVerify.ts` · `oidcBinding.ts` · `identityCert.ts`). "Sign in with Google," without a backend and without trusting the transport: - **`oidcVerify`** — hand-rolled **RS256-only** ID-token verification over WebCrypto. `alg:"none"`, HS\* (the classic algorithm-confusion attack) and ES\* are rejected outright — the algorithm is *never* taken from the token to pick the verifier. The key is chosen by `kid` from the provider's published **JWKS**; an unknown `kid` fails closed. - **`oidcBinding`** — the serverless heart. At sign-in the user sets the token's **`nonce`** to a hash of *their* WebRTC **DTLS certificate fingerprint**. Every peer recomputes that hash from the cert it **actually handshook with** and requires a match, so a token is valid **only for the peer holding that cert's private key**. - **`identityCert`** — one **pinned** cert, reused for every connection, private key never leaving the browser → the binding is **non-transferable**. The payoff: 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. **4.5 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}`; the authority admits it 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. **4.6 Capabilities** (`capabilities.ts`). A per-participant **`Grant`** = what it may **perceive** (`read-chat`, `read-roster`, `read-media`, `read-files`, `see-screen`, `hear-audio`, `receive-directed`) and **act** (`send-chat`, `speak`, `act`), plus agent disclosures (`backend`, `egress`). `defaultGrant('agent')` is **least-privilege** — an agent perceives the conversation but acts on nothing until explicitly granted. **4.7 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. ## 5. 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 (`poll`, `title`, `state`, `pending`, `decide`, `import`) 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. Key possession (§2) proves you're *a* member; it does not say *which*. A live Kibitz call attributes each message via the DTLS-bound connection; an async Space has **no live connection to bind to**, so each **ledger entry carries its own signature**. **The grant (`gt`).** A member holds an ECDSA signing keypair. Their grant is a Kibitz **invite token** — byte-identical to `inviteToken.signPayload` — with the payload superset `{ 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` in the link. Kibitz's own verifier reads `name`/`room`/`exp` and ignores `spk`. **Strict superset — no new scheme.** **The entry signature.** 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 reader or the render then checks, purely over the link's **public** material: `verifyGrant(gt, gk, {room, now})` yields `(name, spk)`; then the entry's claimed name must match and its signature verify against `spk`. → a **verified author, never rooted in the platform**. **In the link.** The fragment carries `gk` (room-invite pubkey), `gt` (the grant) and `sk` (the member's signing **private** key), using Kibitz `g*` param names — all fragment-only, never sent. **What the pure invite-grant path cannot prove**, and what closes it: a signing key is a bearer credential, so possession is enough — it can't show the holder is *present now*. The **email/Google path** binds a **fresh, externally-issued OIDC identity to the member's signing key** (`nonce = spkNonce(spk)`), so an entry proves not merely "signed by the keyholder" but "signed by the keyholder who authenticated as **this Google account, now**". A leaked link is then no longer enough to post. ## 6. One definition, three verifiers §1.3 said the verifier can be anyone. That is what lets the *same* credential model span three runtimes. The only things that change are **who runs the check** and **what fresh value the credential is bound to** so it can't be replayed. | | **Live P2P call** (Kibitz) | **Async P2P read** (Witbitz) | **Async server verify** (Witbitz) | |---|---|---|---| | Who verifies | a **peer** — no server | **any reader**, at read time — no server | the **certified-ephemeral function** — the only party present | | Membership | admitted by the join gate over presence | **hold the key** (`commit(mk) == mkCommit`) | same | | Freshness / anti-replay | the live **DTLS cert fingerprint** | the per-entry **signature** over a canonical core | a **server-issued challenge**, folded into the signed core | | Presence | co-present over the connection | none — verify what's written | none — a fresh challenge stands in | | 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 | The unifying trick is a **nonce/challenge binding**: bind the credential to a fresh value only the right holder could have used. Live, that value is the DTLS fingerprint. Async, there is no connection, so it becomes the member's **signing key** (identity) plus a **server-issued challenge** (presence). **Async did not need a weaker model. It needed a different anchor for the same model.** ## 7. Mechanism for mechanism | Kibitz (live P2P) | Witbitz (async) | Same / changed | |---|---|---| | `inviteToken` signed invite | `spaceEntrySig` grant `{name, room, exp, spk, caps?}` | **Byte-identical** wire format (no import). Adds `spk` + optional `caps`. | | DTLS-bound attribution | **per-entry signature** over `{room, author, ts, kind, id, text, chal?}` | Attribution moves from the connection to a signature verified **at read time**. | | `oidcVerify` + `oidcBinding` | `spaceOidc` — RS256-only vs Google JWKS, bound to the **signing key** | Same RS256/JWKS/`email_verified` discipline; nonce binds to the signing key, not a DTLS cert. Verified inside the render and **discarded**. | | cert-binding = presence proof | `spaceChallenge` — server-issued HMAC challenge, folded into the **signed** entry core | The member's existing signature also covers the challenge. Single-use via ledger de-dup. | | `agentKey` assertion + `agentKeys` | `spaceAgentKeys` — the **same `AgentEntry`** | One authored `AgentEntry` admits an agent **live *and* async**. | | `capabilities.Grant` | `spaceCaps` — **identical** vocabulary | A Grant crosses live ↔ async unchanged. | | mesh perceive-gating | `spaceScopedView` — seal the entitled slice to the agent's **box key**, never `mk` | An external agent reads only what its caps allow. | | signed `roomManifest` roster | the **mk-sealed config** (`admission`, allow-list, `agentKeys`, `revoked`) | The policy is **ciphertext at rest** — the platform can't read *who* is allowed. | **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 the length of one turn; an OIDC email it verifies is **transient**, never persisted; and reads it serves an agent are **sealed to that agent's key**. The server *verifies* without being *trusted with content*. ## 8. Admission — where it comes from *Getting into* a Space differs by path: - **Live call — this *is* the Kibitz engine.** Admission uses Kibitz's join gate unchanged: open, signed invites, name list, cert-bound OIDC/Google, email + mailed code. Witbitz has these because the live call **runs Kibitz**, not because code was copied. - **Async Space — the whole gate, carried over.** Async has a counterpart for **every** Kibitz method (§7). Admission is **key possession** (§2) plus, per the Space's policy, one of those credentials. **Who sets that policy** — the owner, and at which tier — is [the owner rule](./the-owner-rule.md). ## 9. Lifecycle — removal, rotation, recovery 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. ### 9.1 What happens 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, so the guarantee holds 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 — so 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 to the link reaches into their browser to delete > it. You cannot **retract** a held key — only **abandon** it. Revocation still works because of a subtlety in the > thin-client model: **the client never decrypts — the server does.** So the server is a **decryption oracle**, and > cutting someone means **denying the oracle**, not un-giving the key. (There is also no "their slice" to strip — > everyone holds the *same* `mk`; you remove them from the **policy**, not the key.) > > | | New secret? | Cuts them because… | Trust assumption | > |---|---|---|---| > | **Keep `mk`, drop from the allow-list** — shipped | No | the **server** refuses to decrypt-and-return (`403`) | you trust the platform — which already holds plaintext in-use | > | **Epoch re-key** — Bridge; single-Space designed | Yes | the **new ciphertext** isn't sealed to a key they hold | none — holds even if the server is bypassed | > > So dropping the member from the allow-list is a *complete, shipped* revocation for an ordinary private Space. A new > secret buys exactly one thing: the cut holding **without trusting the platform**. ### 9.2 Can a removed member decrypt history? Can they receive future entries? History they could already read, they retain — a re-key protects the **future**, not the past. There is no cryptographic un-see; where it matters, compartmentalize by epoch from the start. Future entries: **server-enforced, no** (their `poll` is `403`); **crypto-strong, also no** (sealed to an epoch key they were never given). ### 9.3 How are links and keys rotated? Rotation = **open a new epoch, re-seal forward, hand the remaining members the new key.** The Bridge does exactly this on every membership change. On the pure-link path today, rotation means issuing new member links; per-member grants are already independent and expiring, so one member rotates without disturbing the others. The friction a *link* adds — rotating means re-sharing a URL — is what [the Companion](./the-companion.md) removes: the new epoch key is delivered to a **device**, not pasted into a new link. ### 9.4 Recovery after losing every device **Today: they don't — by design.** The room key lives in the link/device and there is **deliberately no operator recovery key** (an operator key would be a backdoor — the whole point of the double blind). "Lose every copy, lose the data" is the honest current tradeoff. The **roadmap** answer is *user-held* recovery, never operator-held: a recovery code the user stores, and/or **k-of-n social recovery** (the same Shamir split the couple key already uses), with the Companion safeguarding and syncing those shares. **Recovery moves to the user, not to us.** ### 9.5 Replacing a compromised identity **Yes, without rebuilding the Space** — this is what per-member identity buys. Each member is a **signing key** bound by a grant, not the room itself: **revoke** their grant (shipped), **admit a new key** (a fresh grant / `AgentEntry`), and **re-key the epoch** (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, not a migration. ### 9.6 What metadata remains visible 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. ## 10. 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.** ## 11. 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. - **Blind at rest** — the policy is sealed under `mk`; verified identities are transient. ## 12. Status | Piece | Live P2P (Kibitz) | Async (Witbitz) | |---|---|---| | 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`) | | Verified host / moderation | shipped | delegated authority (propose → approve → execute) | | 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 | **designed** — roadmap, never operator-held | | Per-member identity — couple (2-of-2); distinct invite links | n/a | **not yet** (solo signed path live) | ## 13. Code map | Concern | Kibitz (live P2P) | Witbitz (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 | `src/core/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`) | | 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 owner rule — the owner decides who may join, and at what tier > **Status: built · opt-in · behavior-neutral — not yet enabled in production.** The full path below is implemented and > tested end-to-end for **both** trust-root modes: **on-prem**, where the owner pins a single `OWNER_POLICY_KEY`, and > **hosted multi-tenant**, where a **tenant registry** (`OWNER_REGISTRY=1`) resolves the trusted key *by the room's app* > so each governed room is rooted in whichever registered tenant owns it (a policy for an unregistered app, or one signed > by the wrong key, is refused). Also built: the create-time mandate, the render's integrity gate, tier-driven > capabilities, client peer verification, and an operator CLI (`tools/owner-policy.mjs`) to mint keys, sign policies, and > onboard tenants. It stays **inert until an owner opts in** — keys unset, rooms behave exactly as before, the AWS build > is unchanged. Also built (hosted): a **tenant-key onboarding endpoint** — `op:'register-owner'`, gated by a platform-signed grant > scoped to the app + owner-key id, create-once — so a tenant self-registers its owner key instead of the operator > running the CLI. And **key rotation** (`op:'rotate-owner'`, authorized by the current key) — the render verifies each > room against the specific key its marker names, so rotating doesn't break existing rooms, and a `revoke` cuts a > compromised key. **The owner rule is now complete end-to-end.** This is how an *app owner* gets authority over > admission without breaking the content-blind model. ## The gap A Witbitz room is a door whose key is the link. Whoever creates the room picks the lock, and the choice is sealed — even the platform can't read it. Great for a private room 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."* A creator can leave the door open, and the owner can neither see nor override it. Admission is the creator's private choice; there is no owner authority above it. This is the design that adds one — turning **"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 version: 3 // for rotation 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"] } // what a room may be created as notBefore, notAfter } → 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**. `open` is anonymous with read-only caps (a freemium/preview tier); `gold` is a verified user carrying a specific entitlement claim, with full capabilities. One signed table spans anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case. ## Where the owner's authority actually lives Two anchors, because two different parties must verify the policy: - **The room-creation credential** — hosted, the owner's **tenant key**; on-prem, the owner's own create endpoint. This is what makes a room *belong* to the owner: an owner-room can only be minted through the owner's credential. - **A pinned copy of `ownerKey`** — baked into the owner's app and published at a well-known URL — so the *render* and *other members* can independently check that a room's policy was really signed by this owner. ## Bound to a room at birth When a room is created through the owner's credential, `create` stamps two things onto it: 1. **The signed policy, sealed** in the room config — so the render can read and enforce it with the room key. 2. **A cleartext marker** on the public record — `{ app, ownerKeyId, policyHash }`, generalizing today's `gated` flag — so the content-blind platform and the keyless read paths know "this room is owner-gated" without reading anything. And the owner's create path **refuses to mint a room that doesn't carry the policy**. The room is *born* gated; there is no "open" escape hatch under the owner's app. ## Only the app can mint its rooms (room-genesis) There is a subtlety the policy alone doesn't cover: the signed policy is a **reusable, public artifact** — it's the same document on every one of the app's rooms, so anyone could copy it off one room and stamp it on a room *they* create. Verifying the policy proves "*governed by* app X's rule," not "*created under* X's authority." Left there, an attacker could stand up a look-alike room with X's exact governance and sign-in and phish X's users. So stamping a policy at creation also requires a **room-auth** — a signature *by X's owner key over this specific room*. Only the app owner (its backend) holds the key, so only the app can mint a room stamped with its app; the room id is in the signed core, so a room-auth for one room can't authorize another, and it's bound to the *resolved* owner key (the single key on-prem, or the tenant's registered key hosted), so it's app-specific. Create refuses a stamped policy with no valid room-auth. This is what turns "governed by X's rule" into **"a genuine room of X's."** ## How a member's tier is decided — and why they can't self-promote Tier comes only from a **signed** source, never the client's word: - **IdP claims** — the owner's login token already carries groups/roles. The policy maps claim → tier, so the *owner's directory* is the source of truth for who is gold. Sign in, and the token's claim picks the tier. - **A signed grant** — a room admin can promote a specific member for a specific room (the existing signed-capability grant, see [Delegated authority](https://docs.witbitz.chat/docs/delegated-authority.md)), overriding upward. - **The room's tier** — a room is created *as* a tier; a member's effective tier is their entitlement, bounded by what the room admits (the room is the ceiling). A tampered client can't upgrade itself, because it can't forge the IdP claim or the grant — and the **render**, not the client, resolves the tier and enforces it. ## How it's enforced — four layers, 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 member's tier, and gates each action with the capability gate. Policy absent or signature invalid → refuse. | the certified render (already decrypts per turn to check admission) | | **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 reads the room policy and **verifies it against its own pinned `ownerKey`** — and won't join a room whose policy is missing or unrecognized | every member's app (peer check) | The render already does the per-turn identity gate and the capability gate; the **new** move is that the policy now comes from the *owner-signed artifact* and the render **refuses to run if it doesn't verify** — instead of trusting the creator's choice. The boolean tier caps drop straight into the existing gate; the one genuinely new enforcement is per-tier **limits** (rate / token / tool budget), and the platform already meters sessions and tools — so a gold tier is just a higher metered ceiling. Which means the ladder doubles as the **owner's pricing surface**: admission, capability, and monetization collapse into one signed table. ## Why nobody can strip or weaken it - **Forge a weaker rule?** No — it must be signed by `ownerKey`. - **Omit it at creation?** No — the owner's create path won't mint the room, and the render refuses a room with no valid policy. - **Delete the policy or marker later?** The render refuses a room whose policy doesn't verify; the marker guards the blind paths; peers reject unrecognized rooms. - **Impersonate the app** by stamping its public policy on your own room? No — creation requires a room-auth signed by the app's owner key over that specific room (room-genesis, above), so only the app can mint a room stamped with its app. - **The only escape** is creating a room *outside* the owner's app — which isn't "a room in the owner's app," and the owner's users' apps won't join it. So *"every room in my app requires my login"* holds exactly. ## 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. Three distinct steps, and only one is a fresh signup: 1. **Owner registration (once, the trust root).** The owner registers the app: its `ownerKey`, its IdP (issuer + JWKS, the [`SPACE_OIDC_ISSUERS`](https://docs.witbitz.chat/docs/on-prem.md) entry), and its claim → tier map. Hosted, this is tenant onboarding proven with the tenant key; on-prem, it's config. After it, the render and peers know *"tenant Acme's authoritative ownerKey is X."* 2. **User registration (per user, in the owner's directory).** However the owner's IdP already does it — enterprise SSO / SCIM provisioning, self-signup in the owner's flow, or admin invite. The tier is a group the owner assigns, so a user can't self-register as gold. If the owner has no directory, they can use a managed auth provider or Witbitz's email-code issuer *as* the directory — but the owner still owns the "who is gold" data. 3. **Device enrollment (per device).** On first use, the app mints a signing key and binds it to the identity at sign-in. One identity, many devices, each enrolled independently; losing a device just re-enrolls a new key against the same registration. So **joining a room is authentication, not registration** — the room federates a sign-in against a registration that already exists and reads the tier from the signed token: ``` register once (owner's directory) → owner assigns a tier (a group) → open a room link → app forces sign-in via the owner's IdP → token {identity + tier claim} bound to the device key → render admits at that tier ``` Revocation is symmetric: remove the user from the directory (or the `gold` group) and their *next* sign-in fails or 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 attested client](https://docs.witbitz.chat/docs/the-attested-client.md). They compose: a tier's `admit` can *additionally* require an attested client (e.g. "gold requires the attested app"), but identity-tiers work on the plain web with no attestation in sight. Keep them separate — one is about the person, the other about the code. ## The honest trade Requiring registration deliberately reintroduces a **central authority** — the owner's directory — into a model that is otherwise account-free and link-based. That's not a regression; it's exactly the control an owner asks for, and it is **opt-in per app/room**: governed, tiered rooms federate to a directory; open link-rooms don't. The owner chooses where on the spectrum each room sits. 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. ## What's reused, and what's new - **Reused:** the sealed `admission`/`allow`/`revoked` config + the render's per-turn gate, the `gated` cleartext marker, pluggable OIDC issuers, tenant keys, the signed-grant **capability gate**, and session/tool metering. - **New:** (1) the signed `AppPolicy` artifact + a pinned `ownerKey`; (2) the create-time *mandate* through the owner credential; (3) the render's *signature check* + refuse-if-invalid; (4) peer verification in the app; (5) the tier table + claim → tier resolution + per-tier limits wired to metering. One line: **the owner's signed policy replaces the creator's free choice — stamped on every room at birth through a credential only the owner holds, resolving each federated sign-in to a tier, and enforced in three independent places (render, blind paths, peers) — so a room's authority moves from "whoever holds the link" to "whoever holds the owner key."** --- # 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](./room-link-auth.md) (membership + identity) and > [the double blind](./the-double-blind.md) (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; at rest it is opaque bytes. This is the persistent Space memory. ## 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](./room-link-auth.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 sealed blob is never sent to the client — the client is thin (sends turns, receives rendered plaintext, never touches ciphertext or crypto). *Deferred:* reaching an **away** member whose app is fully closed (polling needs a running app) — real-time push/email is a later cut. **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](./room-link-auth.md)). Live + proven in prod: a token-less `poll` to an email-gated Space returns `403`. An **open** Space is unchanged — the link reads. ## 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 content-blind read (etag → plaintext delta); on an email-gated Space it requires an allow-listed sign-in | | `decide` | approve/deny a pending proposal (delegated authority) | | `pending` | list pending proposals | | `import` | seed the Space from a public ChatGPT share link (SSRF-guarded) | | `title` / `state` | a sealed room title; the shared co-edited widget doc | ## 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](./delegated-authority.md). ## 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](./delegated-authority.md)) | ## 8. Status Live in production: the `/space` **zip** Lambda (agent brain as a function) + the Spaces app at `witbitz-spaces.pages.dev`. The turn core, transport (mk-commit), content-blind poll, delegated authority, and per-member signed identity (solo) are all shipped. Deferred: real-time away-notification (closed app); compaction of long ledgers; the couple (2-of-2) signed path. --- # The Double Blind — verifiable privacy 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. This doc is the whole argument in one place: what holds today, exactly where it stops, and **Sealed Spaces** (§5) — the opt-in tier that turns the remaining gap from a behavioural promise into a property of the build. > See also: [async Spaces](./async-spaces.md) (the sealed ledger + decrypt-once render), > [the Space link is the auth](./room-link-auth.md) (the keys live in the link), and > [Verify it yourself](./verify.md) (run the checks). --- ## 1. Ciphertext at rest → 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)`, [room-link-auth](./room-link-auth.md)). At rest, to the operator, a Space is opaque bytes — storage and transport are **content-blind**. There is **no operator recovery key.** Every record is sealed to the **room key alone** — the platform writes no support or recovery recipient of any kind, so there is nothing readable-by-the-operator to read at rest. The double blind is the only configuration that runs in production. ## 2. 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 §3–4](./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 at rest. Keeping it **one** auditable path is exactly what makes the attested tier tractable. **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 §5 exists. ## 3. 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 (§4). ## 4. 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**; see §6. ## 5. Sealed Spaces — the tier that removes the key from our reach *Opt-in, and a **tier** — not a replacement.* Today's Spaces are double-blind, but §2's blindness is **behavioural**: the operator is trusted not to look at a key it does, briefly, receive. A single capture defeats it — a logging toggle, a compelled order, a compromised render reads the entire history, past **and** future, because the key arrives on a schedule and nothing bounds one room's exposure. A Sealed Space takes the key off that schedule. **The server never receives `mk`. There is no capture to toggle.** The platform stores ciphertext and holds exactly one capability — the ability to **append**, never to **read**. ### 5.1 The design — four changes, each assembling a primitive already in the platform 1. **The ledger becomes an append-only log of individually sealed entries** — not one snapshot re-sealed on every write. This is the load-bearing move: appending no longer requires reading, which is exactly what today fuses "write" to "hold the key." 2. **The client decrypts locally.** It syncs sealed history once, caches it on the device, then pulls sealed deltas by cursor — the same poll shape as today, just opaque to the server. 3. **A session-scoped, ephemeral actor does the reasoning.** On the first turn the client seals the assembled context *to the actor's session public key*; after that the actor receives only new messages. It never sees `mk`, holds nothing across sessions, and is evicted on idle. 4. **Write-back is a write-only capability.** The actor appends its reply by sealing to the Space's public write key (`sealTo` / `openBox`). It can add to the ledger without being able to open it. **The net:** the platform routes ciphertext, stores ciphertext, and can append ciphertext. It cannot read the room. ### 5.2 What it protects — and what it doesn't A security claim is only worth the threat model it names. Three columns, not two. **Structurally protected:** at-rest capture (storage dumps, a compelled disk or database order, a logging toggle, a compromised render — the platform holds only ciphertext and a write-only key); and **blast radius**, because per-Space epoch keys mean one Space's compromise doesn't cascade and rotation bounds it going forward. **Not protected**, stated plainly: - **The live session** is plaintext to the ephemeral actor while it reasons. Sealed Spaces closes the *at-rest* hole; it does not make the AI compute over data it cannot read. That is the [attested tier](./the-attested-tier.md). - **The endpoints.** A compromised member device sees plaintext; true of every end-to-end system. - **Metadata** — who is in a Space, when they post, how large the messages are. Sealing content doesn't seal traffic shape. Naming the third column is not a weakness. It is the honest meaning of *content-blind*: blind **at rest**, bounded **in use**. ### 5.3 What it costs - **No member-absent turns.** With no client present, nothing decrypts the context to feed the actor — so "the agent acts while everyone's away" ([delegated authority](./delegated-authority.md)) doesn't exist in a Sealed Space. This is why Sealed is a *choice*, not the default. - **Prompt caching, likely lost.** An ephemeral per-session actor re-receives the full context each session, so server-side prefix caching probably goes away. **Likely the largest recurring cost; worth measuring before committing.** A **warm-actor** variant — residency billed, context held longer — is the dial that trades a little blindness back for cache. - **Cold-device sync.** A new device pulls sealed history before the first render — a first-open cost, not per-turn. - **Actor residency** is billed while a session is open; not scale-to-zero for the length of a burst. - **Forward-only revocation.** Removing a member is an epoch re-key — the mechanism [the Bridge](./the-bridge.md) already runs. It cannot un-share what they already read; inherent to end-to-end, and worth saying out loud. ### 5.4 Status **In hand:** per-entry sealed append and content-blind polling (scaffolded behind a flag, inert until enabled); `sealTo`/`openBox` for write-only append; epoch re-key in the Bridge; the reproducible-build + egress-lock + certificate stack. **To build:** the sealed client → actor session channel; ephemeral-actor lifecycle and idle eviction; cursor-based sealed-delta sync as the default read path; epoch-key distribution to members without the platform in the loop. **To measure first:** the real prompt-caching cost on a busy Space. ## 6. Status — what holds today Being precise, because this is exactly where a claim can overreach: | Property | Status | |---|---| | Ciphertext at rest (sealed ledger, platform holds no key) | **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)) | | Sealed Spaces (the platform never receives `mk`) — §5 | **designed** — primitives in hand, the session channel is not built | | Attested tier (operator provably blind **in use**) | **built & independently verifiable — NOT on production traffic** ([the attested tier](./the-attested-tier.md)) | | Attested **client** (knowing the code running on the user's side) | **design** ([the attested client](./the-attested-client.md)) | ## 7. 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 **built and independently verifiable** — though not yet serving production traffic. So the honest position is no longer "a direction we're building toward": four checks run in a stranger's terminal today, and the fifth is demonstrated on hardware and waiting on operations rather than on invention. What remains genuinely unbuilt is Sealed Spaces (§5) and the attested **client**. ## 8. 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) | [github.com/kibitz-chat/witbitz-render](https://github.com/kibitz-chat/witbitz-render) | --- *Sealed Spaces is not "trust us less." It is "you don't have to." The current tier remains for the workflows that need an always-on agent; Sealed is for the rooms where the key should never leave your hands — and where you can prove it didn't.* --- # The live call — what protects it, and what you can check The other pages here describe the **async Space**: a sealed ledger, a content-blind platform, a decrypt-once render. A live call is a **different guarantee with a different shape**, and conflating the two is the easiest mistake to make about Witbitz. This page is the call's half. > **One line:** a call's audio, video and chat are end-to-end encrypted between browsers and no server in the > path can decode them — but the way you'd *verify* that is currently not exposed in Witbitz Call's UI (§4), > and unlike upstream Kibitz, media here goes through a relay **by default** (§2). > Witbitz Call **is** the [Kibitz](https://kibitz.chat) engine, rebranded — a pinned dependency, not a fork. The > mechanisms below are Kibitz's; the configuration is ours, and it differs in two ways that matter. Engine-level > detail: [kibitz.chat/security](https://kibitz.chat/security). --- ## 1. Media — end-to-end encrypted Calls use WebRTC. Audio and video are encrypted with **DTLS-SRTP**, and the keys are negotiated directly between the participating browsers — they never leave the devices. There is **no SFU or media server** that decodes, mixes or records anything. Chat, shared links and directed messages travel the same way, over direct DTLS-encrypted data channels between each pair; no participant relays another's content. The single assumption is that those keys are exchanged honestly. That happens in signalling (§3), and §4 is how you'd check it. ## 2. ⚑ The relay is the default here, not the exception Upstream Kibitz uses a TURN relay only when a direct connection is impossible, and says most calls never need one. **Witbitz Call sets `FORCE_RELAY_DEFAULT = true`** (`kibitz/src/core/callMedia.ts`) — direct-first was tried and regressed three separate times on real networks, so relay-on is the deliberate default. A `?relay` URL parameter overrides it. What that means, stated plainly: - **The relay cannot read the call.** It forwards packets that are already DTLS-SRTP encrypted; it sees ciphertext, IP addresses and traffic volume. The end-to-end property in §1 is unaffected. - **Participants no longer see each other's IP addresses** — the relay's address stands in. On a direct WebRTC connection each peer learns the other's IP; routing through the relay removes that. This is a privacy *gain* over the direct path, and it is the upstream "hide my IP" behaviour turned on for everyone. - **The relay is ours.** `/api/turn`, same-origin, on Witbitz infrastructure — not Kibitz's. So Witbitz sees the metadata a relay sees: who connected, from what address, for how long, and how much traffic. Not content. - **It costs a little latency**, which is the trade accepted for calls that connect reliably. ## 3. Signalling — introductions only, and the one party that could attack Two browsers cannot find each other unaided. A signalling server briefly exchanges setup data: a temporary peer id, the room name from the link, and ICE candidates (which contain IP addresses). It sees connection metadata, never call content, and stores none of it. **Witbitz Call uses Kibitz's signalling broker** (`signal.kibitz.chat`) — Witbitz has no equivalent service. So for the live-call path there is a third party in the introduction step, and it is worth naming rather than glossing. It matters because signalling is also where the browsers publish their certificate fingerprints. A compromised or compelled signalling server is **the one party that could attempt to sit in the middle of a future call** by substituting a key during setup. A relay cannot — it only ever sees ciphertext. That is exactly the attack §4 exists to catch. ## 4. ⚑ Verifying it — the safety code, and why you cannot use it here today Every WebRTC "end-to-end" claim ultimately trusts the signalling channel to deliver the right keys. The mechanism that removes that trust is a **safety code**: a short emoji code derived from the two browsers' *actual* DTLS certificates — the ones really used on the wire, not the ones named in signalling. Same idea as Signal's safety numbers. Matching codes mean nobody is in the middle; differing codes mean somebody is. The engine implements this fully, including re-warning if a peer reconnects with different key material, and an SSH-style alarm when a previously verified name reappears with a different key. **It is not reachable in Witbitz Call.** The brand sets `VITE_BRAND_HIDE_PRIVACY_CHROME=1`, which suppresses the shield button that opens *Verify* (`kibitz/src/widget/SecondaryControls.tsx`). The call is still end-to-end encrypted — the flag hides UI, it does not change cryptography — but **the affordance that would let you check that claim is currently switched off.** That is the honest position: on this path you are trusting the signalling server, where upstream Kibitz would let you verify it. If the call matters to you at that level, use kibitz.chat, where the shield is present. Exposing it here is a UI decision, not a protocol one, and it is the single change that would make this page's §1 claim checkable rather than asserted. ## 5. Offline — a room on one Wi-Fi, with no internet For a plane, a cabin, a venue with dead cell: one Android device runs a **LAN hub**, everyone else opens the app on the same Wi-Fi, and the room works with no internet at all. The download and setup are at [witbitz.chat/call/host](https://witbitz.chat/call/host). Like the internet broker, the hub is a **coordination point** — it sees who is in the room and carries the handshakes. Content stays end-to-end encrypted: browser-to-browser when they can connect directly, or relayed through the hub's own **LAN TURN as ciphertext** where they cannot (on phone Wi-Fi, iOS hides each device behind an mDNS name the other cannot resolve). The hub never reads it, and it is a box you run on your own network. ## 6. What this protects — and what it does not **Protected:** the content of the call, from the network and from Witbitz. It cannot be recorded server-side because the media never reaches a server in readable form. **Not protected, and not hidden:** - **That a call happened**, its timing, duration and traffic volume — visible to the relay, which is now every call (§2). - **Your IP address from Witbitz.** Hidden from other *participants* by the relay; visible to the relay itself. - **The room is open by link unless gated.** By default anyone with the link can join. A knock-to-admit lobby, and verified/gated rooms (Google identity, mailed code, signed invites, a name list) are set at creation — see [the verified room](./room-link-auth.md), which is the same credential model the async Space uses. - **The signalling channel** (§3), until the safety code is exposed (§4). - **An agent's model backend.** When an AI agent joins, what it perceives leaves the encrypted room to reach a model — that is what an agent is for. It is disclosed to everyone before they join. ## 7. The agent in the call An agent joins as a participant, and the **capability layer** governs it: every peer carries a grant of what it may *perceive* (media, chat, roster, directed data) and *act*. **Humans default to full, agents to read-only.** It is engine-enforced per peer — a withheld peer never receives the bytes — so a read-only agent gets chat but no audio or screen share and can post nothing until granted. The same `Grant` vocabulary crosses to the async Space unchanged ([the verified room §7](./room-link-auth.md)). ## 8. How this differs from a Space — do not conflate them | | **Live call** | **Async Space** | |---|---|---| | Guarantee | E2EE **between browsers**; no server decodes | **content-blind at rest**; one decrypt-once render | | Who could read it | nobody in the path — but see §3, §4 | the render, transiently, while a member is present | | Verified by | a safety code — **not currently exposed** (§4) | four checks you run in a terminal ([verify](./verify.md)) | | Persistence | ephemeral; the room vanishes when everyone leaves | a sealed ledger that persists | | The moat claim | standard WebRTC, done carefully | [the double blind](./the-double-blind.md) | The platform's verifiable-privacy argument is about the **Space**. The call is a well-built E2EE call — a different and more conventional guarantee. Saying so plainly is the point of this page. ## 9. Where the code is | Concern | Where | |---|---| | Media, relay policy (`FORCE_RELAY_DEFAULT`) | `kibitz/src/core/callMedia.ts` | | Safety code / verify UI (hidden by the brand flag) | `kibitz/src/widget/SecondaryControls.tsx` | | Capability grants | `kibitz/src/core/capabilities.ts` | | TURN credentials (ours, same-origin) | `witbitz` `infra/turn.tf` → `/api/turn` | | The engine, open source (Apache-2.0) | [github.com/kibitz-chat/kibitz](https://github.com/kibitz-chat/kibitz) | --- # Delegated Authority — humans keep the final say The second of Witbitz's two guarantees (the first is [the double blind](./the-double-blind.md)). 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`. --- ## 1. 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. ## 2. 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. This is the couples-therapy safety-floor idea raised to the action level: the model reasons, the human authorizes, the runtime gates. ## 3. 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. `makeProposingBrain` wires this: it runs the brain with the Space's tools and turns a tool call into a **pending proposal** (via `proposeCallTool`) rather than an execution. 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. ## 4. The audit trail Every step — propose / approve / deny / execute (with result or error) — is appended to a single per-room **`authority` doc**: `{ proposals, audit }`, an append-only log. It is **mk-sealed** like the ledger, so it is **content-blind at rest** (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. ## 5. Code map | Concern | Where | |---|---| | propose / decide / execute + the invariant | `agent/delegatedAuthority.mjs` (`propose`, `decide`, `execute`) | | pending list | `agent/delegatedAuthority.mjs` (`pendingProposals`) | | tool-call → proposal glue | `agent/delegatedAuthority.mjs` (`makeProposingBrain`, `proposeCallTool`) | | the ops that drive it | `agent/spaceService.mjs` (`turn` proposes · `decide` approves→executes · `pending`) | | the sealed authority doc | `agent/sessionStore.mjs` (`putDoc`/`getDoc`), sealed via `agent/envelope.mjs` | ## 6. Status Built + tested (the invariant, deny-can't-execute, no-double-run, the ordered audit trail, platform-blindness) and running inside the deployed `/space` Lambda. What it does today is the async **propose → approve → execute + audit** loop; richer policy (spend caps, per-capability authority tiers, org-level delegation from the vision) builds on this primitive. --- # The Companion — an optional device runtime > **Status: roadmap, and now optional.** The three things this was meant to add — notifications, durable > keys, local decryption — the **verifiable web app already does**. The Companion remains a worthwhile > *convenience* (more reliable background notifications, a Secure-Enclave keystore), but it is no longer a > pillar of the platform, and nothing on the roadmap depends on it shipping. A Witbitz app is a **pure static page** — which is exactly what makes its privacy checkable. A static page famously *can't*, on its own, hold a key safely, notify you while you're away, or decrypt locally. The Companion was conceived as the one native app that would carry those "un-static" capabilities for every Witbitz app. Since then, the web caught up. Here is how each capability is handled **today, with no app** — and what a Companion would still add on top. ## How it's handled without the Companion - **Reach-me → installed-PWA push.** Add a Space to the home screen and the platform can reach you with a **content-free** push (a "something happened" tickle); the device fetches and decrypts locally. Works on iOS (16.4+, installed), Android, and desktop. - **Keys → a passkey, not a link.** A **passkey** (WebAuthn, hardware-backed, synced by your provider — 1Password, iCloud Keychain) holds the room secret, so it stops riding the URL. See [the verified room § lifecycle](https://docs.witbitz.chat/docs/room-link-auth.md). - **Recovery → user-held.** An encrypted **recovery** — a code, or a passkey-derived secret — that you control and the server can't read. Lose a link, still get back in. Never an operator key. - **Local decrypt → in the verifiable client.** The reproducibly-built, egress-locked page decrypts in your own tab today; [Sealed Spaces](https://docs.witbitz.chat/docs/the-double-blind.md) is the design that makes the key *never* reach the server at all — structural at-rest blindness, no native app required. That covers the essentials — and the web version is, if anything, **more** verifiable than an app would be: a CSP-locked, reproducible static page is something a third party can check; an app-store binary is not. ## What a Companion would still add A thin native app is worth building as a **convenience**, never a foundation: - **More reliable notifications** than web push, which is limited on iOS and only fires once installed. - **A Secure-Enclave keystore** — hardware-isolated keys, a step beyond browser-held ones. - **One presence across every Space**, and plain app-store legitimacy. ## The honest boundaries - **It does not close the *in-use* gap.** The **agent** turn runs on server compute, so an agent still decrypts server-side until the **attested / on-device inference tier** (see [the double blind](https://docs.witbitz.chat/docs/the-double-blind.md) and the "not protected" column of [Sealed Spaces](https://docs.witbitz.chat/docs/the-double-blind.md)). A client-side Companion doesn't change that. - **It's a convenience, not a requirement.** The platform stands on the **verifiable web** and **Sealed Spaces**; the Companion is a nicety for people who want it, and can arrive whenever — or never — without moving the trust story. ## Where it sits | Capability | Today, no app | A Companion would add | |---|---|---| | Key storage | passkey (hardware-backed) + link fragment | Secure-Enclave keystore | | Recovery | user-held code / passkey-derived secret | same, device-native | | Notifications | content-free push on an installed PWA | more reliable background delivery | | Local decrypt | in the verifiable tab; **Sealed Spaces** removes the key from the server | one audited runtime | | Agent turn (in use) | server-side until the attested tier | unchanged — not what a client app fixes | See also: [the double blind](https://docs.witbitz.chat/docs/the-double-blind.md) (incl. Sealed Spaces) and [the verified room § lifecycle](https://docs.witbitz.chat/docs/room-link-auth.md). --- # Run it in your own boundary — enterprise & on-prem > **Status: shipped and runnable today (VPC tier).** The entire Witbitz runtime — the app, the agent, the sealed > ledger, admission — runs inside your own network, off our cloud, on infrastructure you control. One switch (`AIRGAP=1`) > takes it to zero egress. The one piece still in progress is a *self-hosted model* for the fully air-gapped case > (below). Enterprises increasingly won't send their data — or their AI — to a third party. Witbitz answers that literally: you run the whole thing yourself. And the design keeps this from forking the product — **every external dependency is a single environment switch that defaults to the hosted behavior when unset.** The build we run for the public and the build you run in your tenant are the same reproducible image; you just point its seams at your own infrastructure. Same code, same [verifiable-privacy](https://docs.witbitz.chat/docs/verify.md) properties, your boundary. ## Two tiers | Tier | Boundary | Model | Who it's for | |---|---|---|---| | **VPC** | your cloud tenant; may reach your chosen model API + map services | your own cloud key (OpenAI / Azure) | most enterprise buyers | | **Air-gapped** | no egress at all | a self-hosted model, in-boundary | defense · government · regulated | The VPC tier is a complete, operable product now. The air-gapped tier is one switch away — except for the self-hosted model, which is the remaining substantial piece. ## One command The runtime ships as a single container plus a Compose stack that wires the whole thing — the app, an S3-compatible object store for the sealed ledger, the record store, and a reverse proxy with automatic TLS: ```bash cp space.env.example space.env # your model key + a domain docker compose up -d --build # the whole stack, off our cloud curl https://space.your-co.com/space-config ``` There is no per-app server to stand up and no dependency on our infrastructure. The app the browser loads is the same static, egress-locked, [reproducibly-built](https://docs.witbitz.chat/docs/verify.md) page — now served from your host. ## What each concern maps to Every seam is a switch. Unset, it's the hosted default; set, it points at infrastructure you own. | Concern | Runs on | Switch | |---|---|---| | **Model** | any OpenAI-compatible endpoint — your cloud key, Azure, or a self-hosted vLLM / Ollama | `LLM_BASE_URL` | | **Object store** (the sealed ledger) | MinIO or any S3-compatible store | `S3_ENDPOINT` | | **Records** (Spaces + Bridges) | a local store (single node) or **Postgres** (multi-node HA) | `DATABASE_URL` | | **Secrets** | mounted files — a Kubernetes Secret or a Vault agent — instead of a cloud secrets manager | `SECRETS_BACKEND=file` | | **Who may join** | **your own identity provider** — Okta, Entra, or Keycloak — as a trusted OIDC issuer | `SPACE_OIDC_ISSUERS` | | **Operations** | backup / restore, a retention sweep, bounded log rotation | included in the kit | | **Zero egress** | refuse every outbound path — see below | `AIRGAP=1` | ### Your identity provider An email-gated Space admits members who sign in with an issuer you trust. Register your corporate IdP and it's authoritative — no dependency on any consumer sign-in. It's additive and fail-closed: a registered issuer can't weaken the built-in ones, the audience is checked (so a token minted for another app can't be replayed), and the Space's own membership list still decides who's actually in. The IdP only proves *identity*; the Space decides *admission*. ### Operate it The record store has no silent auto-expiry, so retention is explicit: a sweep reaps Spaces idle past a window you set (records carry no timestamps — they hold no plaintext — so "idle" is measured from the last write). Backup captures the sealed ledger, the records, and the secrets in one archive. Logs rotate with a bound and stream to your stack (Loki / ELK / your SIEM). For high availability, switch the record store to Postgres and run more than one node. ## Air-gap — zero egress `AIRGAP=1` makes the runtime refuse *every* outbound path, and it does so in ways you can check independently: 1. **The browser.** The served Content-Security-Policy drops every external host. Read the header and it names zero off-box destinations — the browser itself refuses any external fetch. 2. **The server.** The map / route proxies stop calling out, and the agent's internet-touching tools (web search, place lookup, flights, external photos, URL fetch) are refused at a single chokepoint — so even a Space configured with them can't reach out. Chat, the agent, document reading, PDF generation, charts and diagrams, and IdP admission all keep working. The one egress this switch can't close is the **model call itself** — point `LLM_BASE_URL` at a self-hosted, in-boundary model or the turn still leaves the network (the runtime warns loudly at boot if you don't). Standing up an open-weights model and assessing its quality against a frontier model is the remaining air-gapped-tier work. ## The trust story gets *stronger*, not weaker Hosted, our signed [`/cert.json`](https://docs.witbitz.chat/docs/verify.md) and reproducible build let a skeptic confirm the operator — us — is **blind to your data in use**. On-prem there is no third-party operator at all, so the cert's meaning inverts into **supply-chain proof**: *the image you are running is byte-for-byte the audited source — nothing was smuggled in.* And air-gapped, "no data leaves" stops being a promise and becomes something you verify on your own terms: - **No data leaves** — read the CSP off the served page: zero external hosts. Confirm the proxies refuse. - **The code is the audited code** — reproduce the build from published source and match the running image's hash; `op:attest` returns the exact build commit the box is running. This is where [verifiable privacy](https://docs.witbitz.chat/docs/room-link-auth.md) is at its strongest: both halves are checkable by you, on hardware you control, without trusting us at all. ## Verify a running deployment ```bash # the egress allowlist, as a header — in air-gap it names no external host: curl -sD- https://space.your-co.com/ -o /dev/null | grep -i content-security-policy # the exact build the box is running: curl -sXPOST https://space.your-co.com/space -H 'content-type: application/json' -d '{"op":"attest"}' ``` ## Talk to us On-prem and air-gapped deployments are handled as enterprise engagements — [hello@witbitz.chat](mailto:hello@witbitz.chat?subject=Witbitz%20on-prem). --- # 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/` > The **Bridge** is a *separate* contract (party signatures, not tenant keys) — see [the Bridge](./the-bridge.md). --- ## 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 at rest. ## 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/redeem` · `POST /wallets/grant` · `POST /checkout` · `GET /usage` **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](./room-link-auth.md). - This page is a summary; the OpenAPI at `/v1/openapi.json` is the source of truth (schemas, per-op auth, examples). --- # The Bridge — governed collaboration between sovereign Spaces The Bridge is Witbitz's forward-looking frontier: a **governed relationship between two or more sovereign Spaces** — *not* another shared room. Each side keeps its own identity, data, policies, and authority; the Bridge defines what may flow, who participates, and how it's audited. The other side need not even be Witbitz — an external agent and its humans can join over signed HTTPS. **The protocol is the product.** > Status: an **early protocol preview**, live at `https://api.witbitz.chat/v1/bridge`. Normative spec: > [bridge-protocol.md](https://docs.witbitz.chat/bridge-protocol.md); wire contract: > [bridge.openapi.yaml](https://docs.witbitz.chat/bridge.openapi.yaml). --- ## 1. The shape — three spaces Two parties each keep a **private prep room** (their people + their agent, sealed under their own key — raw work never leaves), and they meet in **one shared space** — the Bridge — where approved content is placed and humans can talk directly. There is **no neutral host**; the parties co-host. A party is *native* (a Witbitz Space) or *external* (someone else's agent + humans, on their own system) — the two are just implementations of the same wire contract. It generalises to N parties (the shared space is a first-class Space with its own id). ## 2. The five invariants The semantics are stated invariants-first; the ops are just mechanism. 1. **Confinement is structural.** The shared space's epoch key opens *only* the shared space. Private rooms are under different keys — an external or hostile party **can't reach them because it holds no key**, not because a policy says no. 2. **Attribution by signature.** Every entry is signed by the submitting party's key and verified against its public key in the membership record — on the ciphertext. 3. **The platform is content-blind.** Bodies are sealed under a key the platform never holds. It checks membership, capability, epoch, and the hash chain — never the content. 4. **Source-side egress.** Each party approves what it contributes *before* it is sealed into the shared space. Nothing crosses un-reviewed; the platform never reads content to police it. 5. **Governed, revocable membership + trustless audit.** Every join or revoke mints a fresh epoch key; a joiner gets no back-history by default; a revoke gives real forward-secrecy. Server-signed **checkpoints** that parties pin + gossip make equivocation detectable. ## 3. Key epochs The shared space is a sequence of **epochs**, each with its own key sealed per-member (to each party's box key). Every **join OR revoke** is an epoch boundary: the new key is sealed only to the new member set. So a **removed** party can't open the next epoch (forward secrecy), and a **joiner** gets no transcript unless policy explicitly seals past epochs to it (invited into a conversation, not handed its history). ## 4. Capabilities Each member carries `caps ⊆ {read, submit, admit, revoke}` → observer (read-only auditor) / party (read+submit) / admitter (+admit/revoke). SUBMIT/ADMIT/REVOKE are capability-gated. ## 5. The ops (live) At `https://api.witbitz.chat/v1/bridge`: | op | what it does | auth | |----|--------------|------| | `POST /spaces` | found a shared Space (JOIN, first party) | entry signature | | `GET /spaces/{s}/members` | the public membership record + sealed key grants | public | | `PUT /spaces/{s}/members` | admit / revoke — a re-key, an epoch boundary | capability | | `POST /spaces/{s}/entries` | submit a signed, sealed entry (a load or a post) | entry signature | | `GET /spaces/{s}/entries` | read the shared thread since a cursor (ciphertext) | public | | `GET /spaces/{s}/checkpoint` | the latest server-signed head, to pin + gossip | public | **Deployed auth is thin-but-sound:** SUBMIT is authenticated by the entry's own signature (verified against the membership pubkey); reads/members/checkpoint are content-blind or public. Per-request `partySignature` (RFC-9421-style) is the documented hardening (`x-status: proposed`) — the guarantees already hold from the key boundary + entry signatures. ## 6. Native or external — the interop boundary The whole point is that the *other side isn't necessarily Witbitz*. A native party and an external party (identity + the protocol crypto only, zero Witbitz-Space code) are two implementations of one contract; the interop was proven end-to-end (a native + an external party co-inhabit one shared space, content-blind). That interop boundary — *own the governance; let the wire be MCP/A2A/signed-HTTPS* — is why the protocol, not any one app, is the product. ## 7. Code map | Concern | Where | |---|---| | Membership + capabilities + key epochs | `agent/bridgeMembership.mjs` | | Signed, hash-chained log + checkpoints | `agent/bridgeLog.mjs` | | Service + party client (native = external) | `agent/bridgeSpace.mjs` | | HTTP binding | `agent/bridgeHandler.mjs`; infra `infra/prod-bridge.tf` | | Normative spec / wire contract | `docs/bridge-protocol.md` · `docs/bridge.openapi.yaml` | ## 8. Status Live over HTTP + verified end-to-end (found → admit an external party → signed+sealed submit → content-blind read → checkpoint; governance rejects a stranger/tamper). It is an **early preview** — the frontier of the platform, not a beta-stable API. The wider vision (org-to-org negotiation, delegated authority across the boundary, a public transparency log) builds on this.