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