# Async Spaces — the agent is a function, not a server The first Witbitz Spaces are **async**: a persistent room that lives over weeks, where the AI is a **function invoked per turn**, not an always-on process. Between turns the Space is cold ciphertext — so an idle Space costs ≈ $0. This is the model the [Spaces app](https://witbitz-spaces.pages.dev) runs on. **Live in production.** > See also: [the Space link is the auth](./identity-and-admission.md) (membership + identity) and > [the double blind](./trust-model.md#the-double-blind) (why the platform can't read a Space). --- ## 1. Agent-as-function A live call runs the agent as a container for the call's whole life. An async Space instead invokes a **turn function** only when a present member acts: > A member posts → the transport invokes the turn with the member's `mk` → the function opens the sealed ledger, > asks the model, appends the reply, re-seals, stores, and drops `mk`. Then the Space is cold again. No held socket, no always-on compute → **idle ≈ $0** (the reason to prefer poll/push over held connections; cost model in the repo's `async-rooms-cost.md`). It stays on AWS (DynamoDB ledger + S3 blobs + Bedrock/LLM in-boundary) so the ciphertext and the inference share one trust boundary. ## 2. The sealed ledger A Space's conversation is one **snapshot sealed under `mk`** and stored as ciphertext (`sessionStore.putLedger`/`getLedger` → `agent/envelope.mjs` `seal`/`open`). It is sealed to the **member key only** — there is **no operator recovery key**. The platform holds no key; to it, a Space is opaque bytes. This is the persistent Space memory. > **How it is sealed.** v2 re-seals the whole ledger on every turn — simple, and O(n) in the history. v3 appends the > new entries as **per-entry sealed boxes**, so appending is O(1) in the delta and a reader can take a content-blind > slice without opening everything. v3 is what production runs; the writer is flag-gated (`SPACE_LEDGER_V3`) and > migrates on write, so the two formats coexist and the change is reversible. ## 3. The turn — a decrypt-once render `agent/asyncTurn.mjs` `runAsyncTurn` is the render, and it is the **one place plaintext exists**: 1. **access control** — `mk` arrives from a present member's client; `agent/spaceTransport.mjs` checks `commit(mk) === the Space's stored commitment` (proves membership + that this turn seals to the ledger's key). 2. **attribution** (for a signed Space) — verify the turn's signature against the member grant, set the **verified** author ([room-link-auth §4](./identity-and-admission.md)). 3. **decrypt-once** — open the sealed ledger, append the incoming entry, run the brain (its tools may produce widgets/artifacts), append the reply, re-seal, `putLedger`. 4. **drop `mk`** — the key is never held past the turn. Between turns nothing runs and nothing is readable. ## 4. Reaching members — poll, content-blind `agent/spacePoll.mjs` is the read side, and it stays blind on the common path: - The client sends its last **etag**; the server computes `etagOf(sealed)` = a hash of the **ciphertext** — it reveals only "changed", never the turn count, and it is computed **without decrypting**. - **Unchanged** → return no entries and **never decrypt** (`mk` unused). - **Changed** → decrypt-once, return only the **plaintext new entries** after the client's cursor, drop `mk`. The committed ledger's plaintext is produced **server-side** — the client is thin: it sends turns and receives **server-decrypted plaintext**. (Its only crypto is minting the key, opening the small in-flight *partial*, and the optional `pollsealed` read below.) **Away members are reached by push** (`agent/webPush.mjs`, ops `subscribe` / `unsubscribe`). The notification is a **content-free tickle**: it carries no message text, only "something happened in this room". The service worker wakes and polls — receiving server-decrypted plaintext, exactly as an in-app read — so the **push provider** learns nothing a poll would not already reveal; content-blindness *against the push provider* survives the notification path. This works in an installed iOS/Android home-screen PWA, with no native app. **Reads are gated too, for a private Space.** On an `admission:'email'` Space the poll (and *every* content op — `title`, `state`, `pending`, `decide`, `import`) requires an **allow-listed Google sign-in** before the server will decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes ([room-link-auth §4](./identity-and-admission.md)). Live + proven in prod: a token-less `poll` to an email-gated Space returns `403`. An **open** Space is unchanged — the link reads. ### The reply arrives progressively A turn does not wait for the model to finish. As the reply is generated it is **sealed and published as a partial** (`agent/asyncTurn.mjs` → `putPartial`), and that sealed partial rides **every** poll response — including the etag-unchanged short-circuit, so it is delivered without disturbing the content-blind fast path. The client opens it with `mk` and reveals it at a readable rate. The value is not a shorter wait. It is that the reader starts *processing* the answer sooner. The partial is cleared the moment the finished entry lands in the ledger, so a live bubble can never duplicate a real message — including on the failure path, where a stream that died after emitting text must also disappear. ## 5. The ops One HTTP surface (`agent/spaceHandler.mjs` → `agent/spaceService.mjs`), at `/space`: | op | what it does | |----|--------------| | `create` | register the public Space record (its key commitment + config + `gk`); the client mints room+mk, the server never sees mk | | `turn` | a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) | | `poll` | the default read — a **content-blind etag** over ciphertext detects changes, then the server returns a **decrypt-once plaintext** delta (plus any sealed partial); on an email-gated Space it requires an allow-listed sign-in | | `pollsealed` | the same history returned **still sealed** — the server takes no key and decrypts nothing; the client opens the envelope itself | | `decide` | approve/deny a pending proposal (delegated authority) | | `pending` | list pending proposals | | `import` | seed the Space from a public ChatGPT share link (SSRF-guarded) or a WhatsApp export | | `title` / `state` | a sealed room title; the shared co-edited widget doc | | `blob` | sealed out-of-line content (images, files). Content-addressed and immutable, so a client may cache a ref forever; the server never opens it | | `subscribe` / `unsubscribe` | Web Push registration for away members (content-free tickle) | | `view` | a **scoped read for an admitted agent**: decrypt with a present member's mk, keep only what the agent's `caps.perceive` allow, and re-seal that slice to the agent's box key. The agent never receives mk | | `fetchview` | the agent collects that sealed slice. Un-gated and content-blind — it is ciphertext only the agent's box private key opens | | `mirror` | append a linked room's entries to this ledger (the private-lane bridge). Deduped by `id`, no agent turn. **The server never joins the two rooms — the device does, holding both keys** | | `policy` | resolve a room's governance + owner key, so a client can verify the room against its own pinned owner *before* trusting it | | `attest` | the running service reports the source it was built from (verifiable trust); cross-check against the signed build certificate | | `invite` | public, no mk: a joining partner fetches the commitment + the sealed share, unseals it with the invite code and combines it with their own share to reconstruct mk. The server only ever hands back ciphertext | ## 6. What runs inside A Space's capabilities are ordinary **tools** in its config (e.g. `search_places` → a shared map widget, `search_flights` → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific twist: a **high-stakes** tool call is deferred to a human approval instead of running in-room — that is [delegated authority](./trust-model.md#delegated-authority). ## 7. Code map | Concern | Where | |---|---| | Turn render (decrypt-once) | `agent/asyncTurn.mjs` (`runAsyncTurn`, `renderLedger`) | | Access control + mk lifecycle | `agent/spaceTransport.mjs`, `agent/spaceService.mjs` (`handleSpaceRequest`) | | Content-blind poll | `agent/spacePoll.mjs` (`etagOf`, `pollSpaceView`) | | Sealed ledger | `agent/sessionStore.mjs`, `agent/envelope.mjs` (`seal`/`open`/`commit`/`newRoomKey`) | | HTTP handler + record store | `agent/spaceHandler.mjs` | | Thin client | `spaces/public/spaceClient.js`, `spaces/public/space.html` | | Delegated authority | `agent/delegatedAuthority.mjs` ([doc](./trust-model.md#delegated-authority)) | ## 8. Status Live in production: the `/space` **zip** Lambda (agent brain as a function) + the Spaces app at `witbitz-spaces.pages.dev`. Shipped: the turn core; transport (mk-commit); content-blind poll; delegated authority; per-member signed identity; progressive (streamed) replies; Web Push for away members; the 2-of-2 code-sealed couple handshake (`spaces/public/invite.js` — the server only ever holds ciphertext under a code it does not have); private lanes; membership forks; email-gated admission; import from ChatGPT and WhatsApp; the built-in tool catalogue. **Ledger v3** — appending only the new entries as per-entry sealed boxes, so a turn's crypto is O(1) in the delta rather than O(n) in the whole history — is **enabled in production** (`SPACE_LEDGER_V3=1`) while the code default stays off, so a rollback is one environment variable. v2 seals the whole blob each turn and remains the fallback. Deferred: delivering an agent's scoped view while **no member is present** (a push-on-turn sealed-view store); compaction of very long ledgers beyond the current bound.