# 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/llms-full.txt. ## Start here - [Overview](https://witbitz.chat/docs/overview.md): the fastest orientation — Spaces, static apps, the runtime underneath, the two guarantees, and where to go next. Read this first. - [Status](https://witbitz.chat/docs/status.md): what is live, private beta, built-but-not-production, and designed. Read this before making security, product, enterprise, or roadmap claims. - [What Witbitz is (portal home)](https://witbitz.chat/docs/): the trusted-runtime idea, the three layers, the double blind, the featured Spaces app, and what you can build (the app topologies). ## Live demos (see the runtime work, then read how) - [Lumen — the app-platform demo](https://witbitz.chat/lumen.md): a plain static HTML file that is secretly a live, shared, persistent, private AI app from a single ` ``` or drive it from script: ```js const space = Witbitz.embed('#slot', { link }) space.on('message', m => …) // someone posted (metadata only) space.on('turn', t => …) // an agent turn finished space.signIn(token) // owner-gated rooms: admit the user at their tier ``` `Witbitz.embed(target, opts)` returns a controller: `on(type, cb)` / `off`, `signIn(token)`, `navigate(to)`, `setTheme(theme)`, `destroy()`, and `iframe`. `` re-exposes it as `el.space`. ## Why it's an iframe — and why that's the point That one line mounts a **cross-origin iframe on the Spaces origin**, not inline DOM in your page. That isn't a limitation to apologize for — it's what keeps the guarantees true when a Space is embedded: - The **verified** Space client runs in **its own origin**. Your page — and every tracker and third-party script on it — **cannot reach into it**, cannot read the room key, cannot read the plaintext. - If a Space were inlined into your page, your page would become part of its trusted computing base, and *content-blind* would quietly stop being true the moment anyone embedded it. The iframe is the isolation boundary. So a drop-in embed **does not cost you the encryption** — the part that touches your users' content still runs only in verified code, in a context your page can't see. ## The bridge (host ⇄ Space) The only channel across an origin boundary is `window.postMessage`. The SDK layers a small, **origin-checked** protocol (JSON-RPC 2.0) on it. It is a **narrow capability seam, not shared memory** — the host can do exactly these things: | Direction | Message | Meaning | |---|---|---| | Space → host | `ready` | the Space handshaked; the bridge is open | | Space → host | `message` | someone posted — **metadata only** (`{id, self, sys}`), never content | | Space → host | `turn` | an agent turn finished | | Space → host | `member` | a member event | | Space → host | `resize` | content height changed (auto-sizes the iframe) | | host → Space | `signIn` | hand in an owner IdP token / signed grant | | host → Space | `navigate` / `theme` | deep-link / theme within limits | Discipline that makes the seam safe: **origin-checked both directions** (the Space only accepts control from the one validated embedder; the host trusts only the Spaces origin), **nothing is posted to `'*'`**, and **the room key never crosses the bridge** — it rides the iframe's URL fragment, client-side, and is never sent to a server. Sign-in is a **verified token**, not a trust-me flag: it's checked against the room's sealed allow-list and resolved to a tier (see [the owner rule](./the-owner-rule.md)). One honesty note: the app that *embeds* a room necessarily holds that room's link, so it holds that room's key — the isolation doesn't hide the embedded room from the embedder. What it protects is (a) the platform-wide guarantee that *Witbitz* can't read content, and (b) the room from your page's *other* scripts and trackers. ## Who may embed — two gates Embedding is **off by default** (`frame-ancestors 'self'`). Turning it on is two layers, deliberately: - **`frame-ancestors` — the app-wide COARSE gate** (browser-enforced). It *has* to be coarse: the browser evaluates the header before any JavaScript runs, when the room isn't even known (especially for `#mk=` fragment links). Set the allowed embed origins for a deployment via `SPACE_EMBED_ORIGINS` (it feeds both this header and the client check from one source, so they can't drift). - **The owner policy — the per-room PRECISE gate** (client-enforced). The owner's **signed** `AppPolicy` may name `embedOrigins` — the origins allowed to embed *that app's* rooms. Because the client holds the room key, it reads the sealed, signed policy itself and **closes the bridge** if this embedder isn't on the list. The server never sees it — content-blindness is preserved. On the hosted multi-tenant platform the owner key is resolved from the tenant registry (`op:'policy'`); on a pinned single-owner deployment it's the baked key. So the browser stops any non-registered origin from framing at all; the owner policy narrows it to *this owner's* origins per room. Fail-closed. ## Orthogonal to what the Space *is* The embed is transport + isolation. Whether the client is *trustworthy code* is a separate axis — [the attested client](./the-attested-client.md); admission and tiers are [the owner rule](./the-owner-rule.md). They compose: an embedded Space is still content-blind, still owner-governed, still verifiable. One line: **`witbitz.chat/embed.js` drops a real, isolated, content-blind Space into any page, talks to it over a narrow origin-checked bridge that never carries your plaintext, and lets the room's owner decide who may embed it.** --- # 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. > **How it is sealed.** v2 re-seals the whole ledger on every turn — simple, and O(n) in the history. v3 appends the > new entries as **per-entry sealed boxes**, so appending is O(1) in the delta and a reader can take a content-blind > slice without opening everything. v3 is what production runs; the writer is flag-gated (`SPACE_LEDGER_V3`) and > migrates on write, so the two formats coexist and the change is reversible. ## 3. The turn — a decrypt-once render `agent/asyncTurn.mjs` `runAsyncTurn` is the render, and it is the **one place plaintext exists**: 1. **access control** — `mk` arrives from a present member's client; `agent/spaceTransport.mjs` checks `commit(mk) === the Space's stored commitment` (proves membership + that this turn seals to the ledger's key). 2. **attribution** (for a signed Space) — verify the turn's signature against the member grant, set the **verified** author ([room-link-auth §4](./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). **Away members are reached by push** (`agent/webPush.mjs`, ops `subscribe` / `unsubscribe`). The notification is a **content-free tickle**: it carries no message text, only "something happened in this room". The service worker wakes, polls, and decrypts locally — so the push service learns nothing a poll would not already reveal, and the content-blind property survives the notification path. This works in an installed iOS/Android home-screen PWA, with no native app. **Reads are gated too, for a private Space.** On an `admission:'email'` Space the poll (and *every* content op — `title`, `state`, `pending`, `decide`, `import`) requires an **allow-listed Google sign-in** before the server will decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes ([room-link-auth §4](./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. ### The reply arrives progressively A turn does not wait for the model to finish. As the reply is generated it is **sealed and published as a partial** (`agent/asyncTurn.mjs` → `putPartial`), and that sealed partial rides **every** poll response — including the etag-unchanged short-circuit, so it is delivered without disturbing the content-blind fast path. The client opens it with `mk` and reveals it at a readable rate. The value is not a shorter wait. It is that the reader starts *processing* the answer sooner. The partial is cleared the moment the finished entry lands in the ledger, so a live bubble can never duplicate a real message — including on the failure path, where a stream that died after emitting text must also disappear. ## 5. The ops One HTTP surface (`agent/spaceHandler.mjs` → `agent/spaceService.mjs`), at `/space`: | op | what it does | |----|--------------| | `create` | register the public Space record (its key commitment + config + `gk`); the client mints room+mk, the server never sees mk | | `turn` | a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) | | `poll` | the content-blind read (etag → plaintext delta, plus any sealed partial); on an email-gated Space it requires an allow-listed sign-in | | `pollsealed` | the same history returned **still sealed** — the server takes no key and decrypts nothing; the client opens the envelope itself | | `decide` | approve/deny a pending proposal (delegated authority) | | `pending` | list pending proposals | | `import` | seed the Space from a public ChatGPT share link (SSRF-guarded) or a WhatsApp export | | `title` / `state` | a sealed room title; the shared co-edited widget doc | | `blob` | sealed out-of-line content (images, files). Content-addressed and immutable, so a client may cache a ref forever; the server never opens it | | `subscribe` / `unsubscribe` | Web Push registration for away members (content-free tickle) | | `view` | a **scoped read for an admitted agent**: decrypt with a present member's mk, keep only what the agent's `caps.perceive` allow, and re-seal that slice to the agent's box key. The agent never receives mk | | `fetchview` | the agent collects that sealed slice. Un-gated and content-blind — it is ciphertext only the agent's box private key opens | | `mirror` | append a linked room's entries to this ledger (the private-lane bridge). Deduped by `id`, no agent turn. **The server never joins the two rooms — the device does, holding both keys** | | `policy` | resolve a room's governance + owner key, so a client can verify the room against its own pinned owner *before* trusting it | | `attest` | the running service reports the source it was built from (verifiable trust); cross-check against the signed build certificate | | `invite` | public, no mk: a joining partner fetches the commitment + the sealed share, unseals it with the invite code and combines it with their own share to reconstruct mk. The server only ever hands back ciphertext | ## 6. What runs inside A Space's capabilities are ordinary **tools** in its config (e.g. `search_places` → a shared map widget, `search_flights` → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific twist: a **high-stakes** tool call is deferred to a human approval instead of running in-room — that is [delegated authority](./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`. Shipped: the turn core; transport (mk-commit); content-blind poll; delegated authority; per-member signed identity; progressive (streamed) replies; Web Push for away members; the 2-of-2 code-sealed couple handshake (`spaces/public/invite.js` — the server only ever holds ciphertext under a code it does not have); private lanes; membership forks; email-gated admission; import from ChatGPT and WhatsApp; the built-in tool catalogue. **Ledger v3** — appending only the new entries as per-entry sealed boxes, so a turn's crypto is O(1) in the delta rather than O(n) in the whole history — is **enabled in production** (`SPACE_LEDGER_V3=1`) while the code default stays off, so a rollback is one environment variable. v2 seals the whole blob each turn and remains the fallback. Deferred: delivering an agent's scoped view while **no member is present** (a push-on-turn sealed-view store); compaction of very long ledgers beyond the current bound. --- # 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. --- # Private lanes A Space can give every participant their **own private lane** beside the shared room: a place to think with an agent that the other side never sees, from which nothing reaches the shared room without the human putting it there. This is the [Bridge](./the-bridge.md)'s "prep room + co-signed crossing", generalized into a primitive any Space can have. --- ## The structure A lane is **its own Space**, not a scope inside the shared one. It has its own room id, its own key, its own ledger. Two rooms, linked on the device that holds both keys. That matters for the privacy claim. A "private scope" inside one room would put both sides' confidential reasoning in a single sealed object, separated only by application logic — and the server would be the thing enforcing the separation. Two rooms means a lane's contents are sealed to a key the other participant simply does not have. **The server never joins the two rooms.** The device does. It holds both keys, reads the shared room, and appends a labelled copy into the lane (`op: 'mirror'`, deduped by entry id, idempotent). To the platform these are two unrelated Spaces. ## What the lane agent can and cannot do Inside the lane, the agent sees the shared room **as an observer**. It is told so explicitly: > THE SHARED ROOM — the live negotiation the other party sees. You OBSERVE it; you cannot post here. To put something in the shared room it must call `cross_to_shared`, and that is a **high-stakes tool**: the agent *proposes*, a human *approves*, and only then does it execute. Propose → approve → execute is the same delegated authority rule the rest of the platform uses — see [Delegated authority](./delegated-authority.md). So the failure mode people actually fear — "my assistant said the quiet part out loud to the other side" — is not guarded by a prompt instruction. It is guarded by the agent having no path to the shared room that does not pass through a human decision. ## What crosses is a message, not a transcript `cross_to_shared` carries the text the human approved. The lane's reasoning, drafts, rejected phrasings and private context stay in the lane. Crossing is an act of composition, not of forwarding. The pending draft can be copied out as text before approving. Editing it in place was built and then removed: it made "what exactly am I approving?" ambiguous, which is the one question the approval step exists to answer. ## In the room - Lanes can be created for every participant **at room creation**, so the topology is the default rather than something to discover later. - The lane opens as a docked panel beside the shared thread; the shared room's messages appear in it as compact, labelled, collapsible lines, so you can read one conversation without losing the other. - A pending crossing renders as the message plus **Approve** / **Not now**. ## Code map | Concern | Where | |---|---| | The crossing capability + guidance | `agent/spaceBridge.mjs` (`cross_to_shared`, `renderSharedForAgent`) | | Provisioning + linking lanes | `agent/spaceProvision.mjs` | | Turn path (bridged context) | `agent/spaceService.mjs`, `agent/asyncTurn.mjs` | | Mirror op (device-side join) | `agent/spaceService.mjs` (`op: 'mirror'`) | | Lane UI | `spaces/public/privateLane.html`, `spaces/public/space.html` (`?panel=1`) | ## Status Live. All four phases — crossing, provisioning, agent runtime, UI — are built and deployed. Design notes, including the alternatives that were rejected, are in `docs/spaces-private-lane.md`. --- # 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/grant` · `POST /checkout` · `GET /usage` - `POST /wallets/redeem` — **two presentations**: a Lemon Squeezy `license_key`, or a blinded issuer `{token, sig, epoch}`. Both mint a `wallet_code` shown once. For a token the **token IS the idempotency key** — a retry gets `409 already_spent`, never a second wallet, so don't send an `Idempotency-Key`. - `POST /epochs` — **issuer role required.** Publish a batch's PUBLIC key and per-token value, after which tokens signed by the matching private key are redeemable. The platform holds only public keys, so it can verify what an issuer signed and can never mint on its behalf. Set once: rotation is a *new* epoch. **Roles.** A tenant is an `app` (builds a product, spends credit) or an `issuer` (holds the customer and the payment relationship, funds wallets from its own prefunded balance) — or both. `grant` and `/epochs` are issuer-only. A tenant with no `roles` recorded predates the split and is treated as holding both. Roles are set by an operator, not self-service. **Tenants** (onboarding + key management) - `POST /tenants/signup` · `POST /tenants` · `POST /tenants/{tenant_id}/status` - `GET /tenant` · `GET/POST /tenant/keys` · `DELETE /tenant/keys/{keyId}` - `PUT /tenant/provider-keys` (BYOK, KMS-sealed) · `PUT /tenant/webhooks` **Spaces** — `POST /space`, the async Space's single op-dispatched endpoint. It is in `/v1` but **keyless**: it authenticates by key possession, not a tenant key (see the note below). **Infra** — `GET /turn` (ephemeral TURN credentials). > **Not at `/v1`:** ratings, `/agents/*`, `/earnings` and `/sessions/{id}` are `x-status: legacy` — they exist on > the older surface but are *not* routed here, and calling them returns 404. They appear in `openapi.full.json` > labelled as such, never in the production spec. ## Access Invite-only during private beta. Email **hello@witbitz.chat** for keys. Everything above is served openly as a contract; the *keys* are what's gated. ## Notes - The async **Spaces** runtime (`/space`) is a separate, content-blind surface (key possession, not tenant keys) — see [async Spaces](./async-spaces.md) and [the Space link is the auth](./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). --- # Trust model Witbitz has two separate trust claims. Keep them separate: 1. **Humans keep authority.** An AI agent can propose work, but the runtime requires a human approval before any high-stakes action executes. 2. **Privacy is verifiable.** A Space is sealed at rest, the app has no backend of its own, the browser egress footprint is locked, and the deployed render can be checked against published source. The docs are intentionally explicit about what is live today and what is not. Start with [Status](./status.md) if you need the shipped/preview/designed map. --- ## What is protected today | Claim | Production status | |---|---| | A gated Space refuses reads and writes without an allow-listed identity | Live and checkable | | The stored Space ledger is ciphertext sealed to the room key | Live and checkable | | There is no operator recovery recipient in the production envelope | Live and checkable | | The app's browser egress is locked to a published allowlist | Live and checkable | | The deployed render publishes a signed build certificate | Live and checkable | | The render source can be rebuilt to the deployed hash | Live and checkable | | High-stakes agent actions require approval | Live and tested | Run the checks: [Verify it yourself](./verify.md) ## What is not hidden Witbitz does not hide everything, and the docs should never imply that it does. - **Metadata remains visible.** The platform sees that a room exists, when it changes, and roughly how large writes are. - **The model provider sees turn content.** A remote model must receive the text it is asked to reason over. - **The render sees plaintext in use today.** Production Spaces open plaintext inside the server-side render during a member's active read or turn. - **Endpoints still matter.** A compromised member device can read what that member can read. - **Availability is not privacy.** The operator can refuse service. The content-blind claim is about reading, not guaranteeing access forever. ## The one plaintext door The current production architecture is content-blind **at rest**. Plaintext opens in one code path: the render. That path runs when a member posts a turn and, for changed reads on the legacy render path, when entries are returned to a client. The reason this is still a useful privacy boundary is that the render is small, published, reproducible, egress-locked, and moving toward attestation. The reason the docs keep calling out the limit is that an ordinary production Space is not yet "unobservable in use." Read: [The double blind](./the-double-blind.md) ## Human authority Tools are capabilities. Some are harmless, like rendering a chart. Others can spend money, send information, modify an external system, or cross an organizational boundary. The runtime treats high-stakes tool calls as proposals: 1. The agent proposes. 2. A human approves or denies. 3. Only an approved proposal executes. 4. The audit trail is sealed with the room. Read: [Delegated authority](./delegated-authority.md) ## Identity and admission A Space starts with key possession: holding the room key proves membership. Gated Spaces add signed identity on top: Google/OIDC identity, signed invites, agent keys, owner policies, or other credential families depending on the room. Read: [Identity and admission](./identity-and-admission.md) ## Stronger tiers | Tier | Status | What it changes | |---|---|---| | **Owner-governed rooms** | Built, opt-in | The app owner can mandate admission tiers and capabilities. | | **Attested server tier** | Built and verifiable, not production traffic | The render runs inside measured Nitro hardware. | | **Sealed Spaces** | Designed | The platform never receives `mk`; it can append but not read. | | **Attested client** | Server seam built; native clients not shipped | A room can require the audited client code, not just an ordinary browser. | ## Where to go next - Check claims: [Verify it yourself](./verify.md) - Understand storage privacy: [Privacy model](./the-double-blind.md) - Understand admission: [Identity and admission](./identity-and-admission.md) - Understand lifecycle: [Member lifecycle](./member-lifecycle.md) - Understand recovery: [Recovery](./recovery.md) - Review action safety: [Delegated authority](./delegated-authority.md) --- # Verify It Yourself Witbitz's claim is **verifiable, not promised** — so this page is where you *check* it, in your own terminal, instead of taking our word. It's the honest counterpart to every "enforcement-proven" line in the other docs: here are the exact commands, and here — just as plainly — is what they do **not** prove yet. > See also: [The Verified Room](./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 \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["spaceEndpoint"])') echo "$SPACE" # the /space Lambda URL the client posts to ``` You now hold exactly what a link-holder holds: the room and its key. The tests below show that **is not enough**. --- ## Test 1 — the read gate: the link alone reveals nothing A token-less `poll` — the read op — with a *valid* room key: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"poll","room":"","mk":"","sinceEtag":"","sinceCount":0}' # → {"error":"not_authorized","reason":"unsigned"} # 403 ``` Holding `mk` returns **no history**. Reads require an allow-listed Google sign-in — the server won't decrypt-and-return without one. ## Test 2 — the write gate: you can't post without an allowed identity A token-less `turn` — the write op: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"turn","room":"","mk":"","message":{"text":"hi"}}' # → {"error":"not_authorized","reason":"unsigned"} # 403 ``` Rejected before anything is written — nothing lands in the Space. ## Test 3 — positive control: the key is valid; the 403 is the *gate*, not a bad key Create a second Space with **"Who can post?" → Anyone with the link** (an `open` Space), and `poll` it the same way with **its** room/key: ```bash curl -sX POST "$SPACE" -H 'content-type: application/json' -w '\n%{http_code}\n' \ -d '{"op":"poll","room":"","mk":"","sinceEtag":"","sinceCount":0}' # → 200 (returns entries) ``` Same request shape, a valid key — but **no admission policy**, so it reads. This is the control that proves Tests 1–2 are the **admission gate** refusing you, not a malformed request or a wrong key. An open Space reads by the link; an email Space does not. ## Test 4 — the egress footprint: the app can only talk to a published allowlist The other three tests check the *server*. This one checks the *app itself*: a static page can `fetch()` anywhere, so "the app doesn't leak your data to third parties" is a real claim — and it's now **browser-enforced**, not promised. Read the enforced Content-Security-Policy straight off the response header — it is the *exhaustive* list of hosts the loaded app is permitted to reach: ```bash curl -sI https://witbitz-spaces.pages.dev/space | grep -i '^content-security-policy:' # default-src 'none'; script-src 'self' 'sha256-…' 'wasm-unsafe-eval' https://accounts.google.com; … # connect-src 'self' blob: https://api.witbitz.chat https://accounts.google.com; … frame-ancestors 'self' ``` Read it directly: - **`connect-src`** is the data path — where the app may send anything. It is exactly **`'self'`** (the same-origin app + its map/tile proxy), **`https://api.witbitz.chat`** (the Space backend), and **`https://accounts.google.com`** (Sign-in). No analytics host, no CDN, no third party. Your data has nowhere else to go — the browser refuses it. - **`script-src`** allows only same-origin files **and each inline block by its `sha256`** — **no `'unsafe-inline'`**. So even injected script can't run, and can't exfiltrate. - **`default-src 'none'`** means anything not named above is denied by default; `form-action` and `object-src` are shut; `frame-ancestors 'self'` blocks cross-origin framing. This is the **egress-allowlist half of the signed certificate** ([the double blind §4–6](./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**. --- # Identity and admission Identity in Witbitz is not "there is an account in our database." A Space is self-authenticated: its link carries the public material needed to verify who belongs, and members hold the corresponding private credentials. This page is the short reader path. The canonical deep dive remains [The Verified Room](./room-link-auth.md). --- ## Base membership: hold the room key Every Space has a room key `mk`. The client mints it and stores only a commitment on the server: ```text commit(mk) = base64url(sha256(mk)) ``` When a member acts, the render checks that the presented key matches the stored commitment. A right key proves the caller holds the Space link. A wrong key opens nothing. This proves "a member is present." It does not by itself prove *which* member. ## Per-member identity: signed entries To attribute entries to a specific person, each member can hold a signing key. The room's public invite key verifies the member grant, and the member's public signing key verifies the entry. The signed core is conceptually: ```json { "room": "sp-example", "author": "ada@example.com", "ts": 1785350400000, "kind": "text", "id": "c:...", "text": "I approve this." } ``` Because the author is inside the signed core, a rename or forged attribution fails verification. ## Admission families Witbitz inherits the credential model from Kibitz and carries it to async Spaces. | Family | What it proves | |---|---| | **Key possession** | The caller holds the Space key. | | **Signed invite** | A creator or room authority granted this member access. | | **OIDC / Google** | The member authenticated as an allow-listed identity. | | **Agent key** | This agent key is allowed by the room. | | **Capability grant** | This participant may perceive or act in specific ways. | | **Owner policy** | The app owner mandated admission tiers for the room. | ## Gated Spaces On an email-gated Space, the link alone is not enough. Reads and writes both require an allow-listed sign-in. That is why the verification page starts with token-less `poll` and `turn` requests that return `403`. The allow-list itself lives in the sealed config, so the platform does not read the membership list at rest. Run the check: [Verify it yourself](./verify.md) ## Who sets admission There are two layers: | Layer | Decides | |---|---| | **The Space creator** | Ordinary open, invite, or email-gated rooms. | | **The app owner** | Owner-governed rooms where every room must obey a signed App Policy. | The owner rule is the enterprise path: an organization can require its own identity provider and tiered capabilities without giving itself access to room content at rest. Read: [Owner-governed rooms](./the-owner-rule.md) ## Live vs designed | Piece | Status | |---|---| | Key-possession membership | Shipped | | Solo signed identity | Live and verified | | Email-gated reads and writes | Live and checkable | | Agent-key admission | Deployed | | Scoped agent reads | Deployed | | 2-of-2 per-member signed path | Not yet | | Crypto-strong single-Space read revocation by epoch re-key | Designed; Bridge has epoch re-key | For the complete status table, see [The Verified Room](./room-link-auth.md#12-status). ## Code map | Concern | File | |---|---| | Room key commit and envelope | `agent/envelope.mjs` | | Client key/link creation | `spaces/public/spaceConfig.js` | | Signed entry/grant | `agent/spaceEntrySig.mjs`, `spaces/public/entrySig.js` | | OIDC verification | `agent/spaceOidc.mjs` | | Admission enforcement | `agent/spaceService.mjs`, `agent/spaceHandler.mjs` | --- # Member lifecycle The hard part of a bearer-link system is not initial entry. It is what happens when people leave, lose devices, rotate keys, or need access cut. This page summarizes the lifecycle model. The full technical version is in [The Verified Room](./room-link-auth.md). --- ## Removal For gated Spaces, removal has two layers: | Layer | Status | Meaning | |---|---|---| | **Cut writes** | Shipped | A revoked member's next turn is refused. | | **Cut reads by server gate** | Shipped for email-gated Spaces | The server refuses to decrypt and return entries to the removed member. | | **Cut reads cryptographically** | **Shipped** for Spaces (the fork) and live in the Bridge | Future content is sealed to a new epoch key the removed member never receives. | The shipped private-Space behavior is useful because the client is thin: a removed member can hold the old key, but the server refuses to act as their decrypting read path. Open Spaces are different. If the link reads, then anyone holding the link can read. ## History vs future Revocation cannot make someone un-see content they already read. That is true for every end-to-end or sealed system. The practical distinction is: - **history**: content a member already saw remains known to them - **future**: new content can be blocked by read gates now and by epoch re-key in the stronger model For rooms with hard compartment boundaries, design epochs up front. ## Membership change is a fork Adding or removing a member is **one epoch operation**, not an edit to a member list. The room forks: a fresh room key and roster, the history carried across, and a sealed pointer left behind so present members follow automatically. The removed member keeps whatever they already had — that cannot be undone — but holds no key to anything after the fork, and no deny-notice has to reach every peer for the guarantee to hold. That delivery problem is what sank the earlier in-place "config update" and lease designs. ## Key rotation Rotation means opening a new epoch and sealing future entries to a fresh key. The Bridge already uses epoch re-key for membership changes. Single-Space epoch re-key is designed but not the ordinary production ledger path today. Per-member signing keys can rotate independently: revoke the old grant and admit the new key. ## Replacing a compromised identity A member identity is a signing key bound by a grant. If that key is compromised: 1. Revoke the old grant. 2. Admit a fresh member key. 3. Re-key the epoch where the crypto-strong future read cut is required. The Space and other members do not need to be rebuilt. ## Metadata that remains Lifecycle operations do not hide traffic shape. The platform may still observe that a room exists, when it changes, and roughly how large writes are. In Google/OIDC-gated rooms, identity appears transiently during verification and is discarded; the membership policy itself is sealed in the config. ## Related pages - [Identity and admission](./identity-and-admission.md) - [Recovery](./recovery.md) - [The Bridge](./the-bridge.md) - [The Verified Room](./room-link-auth.md) --- # Recovery Witbitz deliberately has no operator recovery key. If the platform could recover a Space, the platform could read a Space. Recovery must therefore belong to the user or organization, not to Witbitz as operator. This page explains the current tradeoff and the roadmap. The full lifecycle discussion is in [The Verified Room](./room-link-auth.md). --- ## Current rule If every copy of the room key is lost, the Space is lost. That is not a product flourish; it is the cost of the privacy claim. It is also why the **user-held vault** below exists — the way out is to hold your own spare key, never for the operator to hold one for you. The production envelope is sealed to the room key, not to an operator, support, escrow, or beta recipient. There is no backdoor recipient to call later. You can verify that with the sealed-store check: ```bash curl -sX POST "$SPACE" \ -H 'content-type: application/json' \ -d '{"op":"sealed","room":""}' ``` The recipients list should name the room, not the operator. Read: [Verify it yourself](./verify.md) ## What recovery should be Recovery can exist, but it must be user-held: | Mechanism | Owner | Status | |---|---|---| | Passkey | The user's platform syncs or protects it. | **Shipped** | | Backup code | The user stores it. | **Shipped** | | k-of-n social recovery | Several trusted parties hold shares. | Not built | | Organization key policy | The customer controls it in its own boundary. | On-prem | None of these require Witbitz to hold a readable recovery recipient. ## The shipped vault One vault per account, and **either** enrolled method opens it: 1. A random 32-byte **master secret** identifies and encrypts the vault. 2. Each method stores one **wrapper** — the master secret sealed to that method's own secret. 3. Restoring with any enrolled method opens its wrapper, recovers the same master secret, and opens the one backup. So a passkey and a backup code are not two backups to keep in sync; they are two doors into the same room. Adding or replacing a method rewrites a wrapper, never the data. The server stays content-blind throughout: it stores opaque blobs at **derived** ids and never receives the code, the passkey secret, the vault key, or any plaintext. Wrappers are written *before* the data they unlock, so a half-finished enrolment can never leave a vault nobody can open. ## The offline export Recovery that depends on the platform being reachable is not self-sovereign, so a Space can also be exported to a file you keep, alongside a standalone decryptor page that opens it with no server at all. The export is **opaque by construction**. An earlier version wrote each Space's `room`, `title` and message `count` in the clear next to its sealed ledger — the file was handed to iCloud or Drive as "unreadable ciphertext" and was, except that it announced every conversation's name, id and size to anyone who opened it in a text editor. That metadata is now sealed under the same room key as the ledger it describes, so the decryptor recovers the real names once you load your keys file and shows "Conversation N" until you do. Two things remain visible by construction: **how many** conversations the bundle holds, and **when** it was exported. Hiding the count would mean padding with decoys, which costs more than it buys for a personal backup. ## What the Companion would add The optional Companion was originally imagined as a native runtime for notifications, durable keys, and local decrypt. Much of that is now possible with the installed web app and passkeys, so the Companion is no longer a platform pillar. It may still improve recovery ergonomics: - keep room keys in a device keystore - make rotation less dependent on pasted links - deliver new epoch keys to devices instead of URLs - provide more reliable background notifications Read: [The Companion](./the-companion.md) ## Enterprise/on-prem recovery In an on-prem deployment, the organization controls the runtime boundary. That can include its own backup policy, identity provider, secret storage, and recovery procedure. The privacy story changes from "trust Witbitz not to read" to "verify the image you run and control the boundary yourself." Read: [Enterprise and on-prem](./on-prem.md) ## What not to promise Do not promise that Witbitz can recover a lost Space on hosted production. It cannot, by design. Do not hide the user burden. User-held recovery is safer than operator-held recovery, but it still requires a recovery artifact, another device, a passkey sync provider, a social recovery set, or an organizational process. --- # 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 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."** --- # 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 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) | --- # 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. --- # Deployment options Witbitz can run as a hosted platform, in a customer's cloud boundary, or in an air-gapped environment. The product claim changes slightly in each mode, so the docs keep the deployment model explicit. --- ## The options
HostedWitbitz operates the runtime; builders get tenant keys during private beta.
EnterpriseThe same runtime runs inside a customer cloud boundary.
Air-gapNo external egress; model and replacement services stay local.
BridgeSovereign Spaces collaborate without merging tenants or trust domains.
| Option | Status | Boundary | Best fit | |---|---|---|---| | **Hosted private beta** | Live, invite-only | Witbitz-operated cloud | Early builders and platform integrations. | | **Enterprise VPC / on-prem** | Runnable today | Customer-controlled cloud or network | Enterprise buyers who want their own model, storage, IdP, and ops. | | **Air-gap** | Runtime switch exists; self-hosted model quality remains the main work | No external egress | Regulated, defense, government, and high-control deployments. | | **Bridge** | Early protocol preview | Multiple sovereign Spaces or external parties | Cross-organization collaboration without merging tenants or trust domains. | ## Hosted Hosted is the default private-beta path. Witbitz operates the runtime and exposes the tenant-keyed `/v1` API to invited integrators. The privacy claim rests on the checkable hosted evidence: - admission gate checks - sealed storage transparency - browser egress lock - signed build certificate - reproducible render build Read: [Hosted private beta](./hosted-private-beta.md), [Verify it yourself](./verify.md) ## Enterprise VPC / on-prem In a customer boundary, the same runtime points its seams at customer-controlled services: - model endpoint - object store - record store - secrets backend - identity provider - logs, backup, retention The trust story shifts from "verify Witbitz's hosted deployment" to "verify the image you run and control the boundary yourself." Read: [Enterprise and on-prem](./on-prem.md) ## Air-gap `AIRGAP=1` refuses outbound paths in the browser and server. External tools are disabled unless replaced with local services. The model must also be in-boundary for a true air-gap. Read: [Air-gap mode](./air-gap.md) ## Bridge The Bridge is not a deployment target for one app. It is the cross-boundary protocol for governed collaboration between sovereign Spaces, including external parties that do not run Witbitz internally. Read: [The Bridge](./the-bridge.md) ## Choosing a mode | Need | Start with | |---|---| | Build against the API quickly | Hosted private beta | | Keep data and model calls inside your tenant | Enterprise VPC / on-prem | | Prove no external egress | Air-gap | | Collaborate with another organization without sharing a tenant | Bridge | | Verify hosted privacy claims | Hosted + verify page | | Remove Witbitz as an operator | On-prem | --- # Hosted private beta Hosted Witbitz is invite-only while the platform contract settles. The docs and API contracts are public; tenant keys are gated. This page is the operating model for builders using Witbitz-hosted infrastructure. --- ## What you get | Surface | Status | |---|---| | Spaces app | Live | | `/space` async runtime | Live | | Tenant-keyed `/v1` API | Private beta | | OpenAPI production spec | Public | | Full design OpenAPI | Public, status-labelled | | Owner rule | Built, opt-in, not default | | Bridge API | Early preview | ## Access The access page can redeem an access code and create a tenant key pair: ```text https://witbitz.chat/access ``` Keys are shown once. Secret keys are server-side only. Publishable keys are browser-safe identifiers for public reads and funnel-entry writes, but they do not authenticate an end user. If you do not have an access code, email: ```text hello@witbitz.chat ``` ## API contract The production `/v1` API is documented openly: ```text https://api.witbitz.chat/v1/openapi.json https://api.witbitz.chat/v1/openapi.full.json https://api.witbitz.chat/v1/ ``` During private beta, the contract may change in coordination with invited integrators. Treat `openapi.json` as the production callable surface and `openapi.full.json` as the broader design map. Read: [Platform API](./api-reference.md) ## Hosted trust evidence The hosted mode is designed to be checked from outside: | Evidence | Where | |---|---| | Admission gate refuses unauthorized reads/writes | [Verify it yourself](./verify.md) | | Stored ledger is ciphertext | `op:'sealed'` | | App egress is browser-enforced | Content-Security-Policy | | Deployed render build is signed | `/cert.json` | | Published source reproduces the deployed hash | `/source.tar.gz` and Test 7 | The attested server tier is built and independently verifiable, but ordinary hosted production Spaces do not run inside an enclave today. Read: [Status](./status.md), [Trust model](./trust-model.md) ## Operational boundaries Hosted Witbitz can still see metadata: - room existence - request timing - ciphertext sizes - billing/metering events - transient identity during gated verification Hosted Witbitz cannot read the sealed ledger at rest because there is no operator recovery recipient. ## When hosted is not enough Use on-prem when you need: - customer-controlled model calls - customer-owned object storage and records - customer IdP as the only identity authority - local backup, retention, and logs - no Witbitz-operated runtime in the path Read: [Enterprise and on-prem](./on-prem.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). --- # Air-gap mode Air-gap mode is the deployment setting where Witbitz refuses every external network path it can control. It is for environments where "no data leaves" must be a property you can inspect, not a vendor promise. The short version: set `AIRGAP=1`, point the model to an in-boundary endpoint, and verify the browser and server both name no outside hosts. --- ## Status The runtime has a zero-egress switch. The remaining substantial work for a fully useful air-gapped tier is not the Witbitz runtime itself; it is operating a self-hosted model with the quality the deployment requires, plus local replacements for any external data tools you want to keep. Read: [Enterprise and on-prem](./on-prem.md) ## What `AIRGAP=1` changes | Layer | Behavior | |---|---| | Browser | Content-Security-Policy removes external hosts. | | Server | External map, route, photo, URL, search, and flight paths are refused. | | Tools | Internet-touching tools fail closed even if a Space configured them. | | Model | Must be pointed at an in-boundary endpoint; otherwise the turn still leaves the network. | ## What still works - chat - agent turns against an in-boundary model - document reading - PDF generation - charts - diagrams - shared widget state - IdP admission against an in-boundary identity provider - backup, restore, retention, logs ## What is disabled or needs a local replacement - public web search - external URL import - Google Places photos/search - map tiles, geocode, and routes unless self-hosted - flights lookup unless backed by an in-boundary service - remote model APIs ## Verify the browser boundary Read the CSP header from the served app. In air-gap mode, it should name no external hosts: ```bash curl -sD- https://space.your-co.com/ -o /dev/null | grep -i content-security-policy ``` ## Verify the running build Ask the local runtime what it is: ```bash curl -sX POST https://space.your-co.com/space \ -H 'content-type: application/json' \ -d '{"op":"attest"}' ``` Then compare that build identity to the published source and your own deployment process. ## The trust story Hosted Witbitz asks you to verify the operator's deployment. Air-gapped Witbitz removes the third-party operator from the path. The check becomes supply-chain oriented: - Is the image you run the audited source? - Does the browser policy name no external host? - Do server-side tools refuse outbound access? - Is the model endpoint in-boundary? - Are logs, backups, and retention under your control? ## Related pages - [Deployment options](./deployment-options.md) - [Enterprise and on-prem](./on-prem.md) - [Verify it yourself](./verify.md) --- # 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. --- # 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). --- # The issuer — credit without accounts > There is a shorter overview of this at **[witbitz.chat/issuer](https://witbitz.chat/issuer)** — the same > model with less API detail, including the honest-status table of what is live and what is not. This page > is the one to integrate against. Witbitz has no user accounts. Access is a link you hold; **credit is a credential you hold.** So who takes the payment, and how does the platform know what you paid for? Someone else does. An **issuer** holds the customer, the payment relationship and whatever identity that requires. Witbitz validates and meters and knows neither. If that shape sounds familiar it is the one card networks use: your bank knows who you are, the merchant sees only that the payment cleared, and the scheme in the middle settles between them without holding either side's relationship. --- ## The two sides of a tenant A tenant sits on one side of the platform, or both: | role | what it does | |---|---| | **`app`** | builds a product on rooms, agents and collections, and **spends** credit | | **`issuer`** | holds the customer and the payment relationship, and **funds** wallets from its own prefunded balance | `GET /v1/tenant` reports your `roles`. They are set by an operator — a tenant cannot promote itself to issuer, because issuing is what turns a balance into spendable value. A tenant created before roles existed reports `roles: null` and is treated as holding both, so nothing that worked before the split stopped working. **Issuers are prefunded.** `POST /v1/wallets/grant` debits the issuer's own balance and returns `402 insufficient_credit` when it is short. The platform never extends credit, which bounds what an issuer's failure can cost: users keep credentials already paid for, and nothing outstanding is at risk. ## Epochs — a batch, not a purchase Every credential names an **epoch**: the batch it was minted in, as an opaque id such as `ep_47d5a543e80148deb604`. Credentials used to carry the issuing tenant id instead. Combined with a timestamp that identifies a *specific purchase*, and the metering ledger used to record which wallet paid for each call — so the two together reconstructed a path from *who paid* to *what happened in which room*. Both are gone: the ledger no longer records the wallet at all, and the tenant id became this batch id. Epoch ids are random, never derived from the tenant. A derived id would be recomputable by anyone holding the tenant list, which would rebuild the link. ## Publishing a batch ```http POST /v1/epochs Authorization: Bearer wsk_… (issuer role required) { "pub_key": "-----BEGIN PUBLIC KEY-----…", "cents_per_token": 25 } → 201 { "epoch": "ep_0158043c8123…", "cents_per_token": 25 } ``` Three rules worth knowing before you integrate: - **The platform receives only a PUBLIC key.** Private material is refused at write time. Witbitz can verify what you signed and can never mint on your behalf. - **A token's value is fixed at publication.** Without `cents_per_token` the redeemer would choose what a credential is worth. - **An epoch's key is set once.** Rotation means publishing a *new* epoch. Republishing returns `409` — otherwise you could restate what already-minted tokens are worth after issuing them. You may name the epoch yourself if you need to mint tokens before publishing the key. ## Redeeming ```http POST /v1/wallets/redeem { "token": "tok_…", "sig": "", "epoch": "ep_…" } → 201 { "wallet_code": "WYZK-VG2M", "balance_cents": 25 } ``` The platform verifies your signature against that epoch's published key, records the token spent, and mints a wallet worth the epoch's denomination. - **`409 already_spent`** — the token was redeemed. This is deliberately the same answer for a retry and for a double-spend: **the token is the idempotency key**, so the two are indistinguishable by design. Don't send an `Idempotency-Key` with this presentation; a second wallet is never minted. - **`403 unknown_epoch` / `bad_signature`** — the batch is unpublished, or the presentation does not verify. An epoch with no published key redeems nothing. The `wallet_code` is shown **once**. It is a bearer credential: store it as the user's capability. ## Blinding, and what is honestly true today The design point of a token is that the platform can verify a credential **without being able to link it to an issuance**. That is what blind signatures give you: your client blinds a token, you sign the blinded value without seeing it, and the unblinded result is an ordinary signature. A verifier cannot tell it was produced blindly — which is exactly why Witbitz needs no special cryptography to accept one. **Two things are not true yet, and it would be dishonest to imply otherwise:** 1. **Nothing here blinds anything.** Blinding is your client's job and blind-signing is yours; neither lives in the platform. Until you do both, these are single-use bearer credentials with the right shape — not unlinkable ones. 2. **The epoch→issuer mapping is still held by the platform.** It sits in its own row precisely so it can be moved out, but until it moves, Witbitz can still resolve which issuer minted a batch. There is also a third link on the other path: credentials bought through Lemon Squeezy record the order id, so a **purchased** credential remains linkable to a buyer where a **granted** one no longer is. That is unavoidable while Lemon Squeezy is merchant of record and legally holds the buyer's identity anyway. So the claim today is the smaller, real one: **a granted credential can no longer be linked to a purchase.** The larger claim — that the separation holds mathematically rather than organisationally — waits on blinding and on that mapping moving. ## Settlement Counts, not links. An issuer sells N tokens in an epoch; the platform redeems M of them; the issuer settles on M. Nobody needs to know *which*, which is what lets the books close while unlinkability is preserved. Double-spend is prevented by a permanent record of spent tokens — permanent deliberately, as an expiring record would make every token replayable once it lapsed. ## Where this fits Access is a link, credit is a credential, and identity belongs to whoever is willing to hold it. Witbitz holds neither your users' content nor their identity — which is the same argument as [the double blind](./the-double-blind.md), applied to money instead of messages.