# Async Spaces — the agent is a function, not a server

The first Witbitz Spaces are **async**: a persistent room that lives over weeks, where the AI is a **function
invoked per turn**, not an always-on process. Between turns the Space is cold ciphertext — so an idle Space costs
≈ $0. This is the model the [Spaces app](https://witbitz-spaces.pages.dev) runs on. **Live in production.**

> See also: [the Space link is the auth](./room-link-auth.md) (membership + identity) and
> [the double blind](./the-double-blind.md) (why the platform can't read a Space).

---

## 1. Agent-as-function

A live call runs the agent as a container for the call's whole life. An async Space instead invokes a **turn
function** only when a present member acts:

> A member posts → the transport invokes the turn with the member's `mk` → the function opens the sealed ledger,
> asks the model, appends the reply, re-seals, stores, and drops `mk`. Then the Space is cold again.

No held socket, no always-on compute → **idle ≈ $0** (the reason to prefer poll/push over held connections; cost
model in the repo's `async-rooms-cost.md`). It stays on AWS (DynamoDB ledger + S3 blobs + Bedrock/LLM in-boundary)
so the ciphertext and the inference share one trust boundary.

## 2. The sealed ledger

A Space's conversation is one **snapshot sealed under `mk`** and stored as ciphertext
(`sessionStore.putLedger`/`getLedger` → `agent/envelope.mjs` `seal`/`open`). It is sealed to the **member key
only** — there is **no operator recovery key**. The platform holds no key; at rest it is opaque bytes. This is the
persistent Space memory.

## 3. The turn — a decrypt-once render

`agent/asyncTurn.mjs` `runAsyncTurn` is the render, and it is the **one place plaintext exists**:

1. **access control** — `mk` arrives from a present member's client; `agent/spaceTransport.mjs` checks
   `commit(mk) === the Space's stored commitment` (proves membership + that this turn seals to the ledger's key).
2. **attribution** (for a signed Space) — verify the turn's signature against the member grant, set the
   **verified** author ([room-link-auth §4](./room-link-auth.md)).
3. **decrypt-once** — open the sealed ledger, append the incoming entry, run the brain (its tools may produce
   widgets/artifacts), append the reply, re-seal, `putLedger`.
4. **drop `mk`** — the key is never held past the turn.

Between turns nothing runs and nothing is readable.

## 4. Reaching members — poll, content-blind

`agent/spacePoll.mjs` is the read side, and it stays blind on the common path:

- The client sends its last **etag**; the server computes `etagOf(sealed)` = a hash of the **ciphertext** — it
  reveals only "changed", never the turn count, and it is computed **without decrypting**.
- **Unchanged** → return no entries and **never decrypt** (`mk` unused).
- **Changed** → decrypt-once, return only the **plaintext new entries** after the client's cursor, drop `mk`.

The sealed blob is never sent to the client — the client is thin (sends turns, receives rendered plaintext, never
touches ciphertext or crypto). *Deferred:* reaching an **away** member whose app is fully closed (polling needs a
running app) — real-time push/email is a later cut.

**Reads are gated too, for a private Space.** On an `admission:'email'` Space the poll (and *every* content op —
`title`, `state`, `pending`, `decide`, `import`) requires an **allow-listed Google sign-in** before the server will
decrypt-once and return anything — the room key alone no longer reveals the history. The server is the plaintext
chokepoint (it decrypts in-use), so it enforces the allow-list on reads exactly as on writes ([room-link-auth §4](./room-link-auth.md)).
Live + proven in prod: a token-less `poll` to an email-gated Space returns `403`. An **open** Space is unchanged —
the link reads.

## 5. The ops

One HTTP surface (`agent/spaceHandler.mjs` → `agent/spaceService.mjs`), at `/space`:

| op | what it does |
|----|--------------|
| `create` | register the public Space record (its key commitment + config + `gk`); the client mints room+mk, the server never sees mk |
| `turn` | a member posts; the render replies (or proposes a high-stakes tool call — see delegated authority) |
| `poll` | the content-blind read (etag → plaintext delta); on an email-gated Space it requires an allow-listed sign-in |
| `decide` | approve/deny a pending proposal (delegated authority) |
| `pending` | list pending proposals |
| `import` | seed the Space from a public ChatGPT share link (SSRF-guarded) |
| `title` / `state` | a sealed room title; the shared co-edited widget doc |

## 6. What runs inside

A Space's capabilities are ordinary **tools** in its config (e.g. `search_places` → a shared map widget,
`search_flights` → a flights widget). The turn runs the normal brain tool-loop over them. The one async-specific
twist: a **high-stakes** tool call is deferred to a human approval instead of running in-room — that is
[delegated authority](./delegated-authority.md).

## 7. Code map

| Concern | Where |
|---|---|
| Turn render (decrypt-once) | `agent/asyncTurn.mjs` (`runAsyncTurn`, `renderLedger`) |
| Access control + mk lifecycle | `agent/spaceTransport.mjs`, `agent/spaceService.mjs` (`handleSpaceRequest`) |
| Content-blind poll | `agent/spacePoll.mjs` (`etagOf`, `pollSpaceView`) |
| Sealed ledger | `agent/sessionStore.mjs`, `agent/envelope.mjs` (`seal`/`open`/`commit`/`newRoomKey`) |
| HTTP handler + record store | `agent/spaceHandler.mjs` |
| Thin client | `spaces/public/spaceClient.js`, `spaces/public/space.html` |
| Delegated authority | `agent/delegatedAuthority.mjs` ([doc](./delegated-authority.md)) |

## 8. Status

Live in production: the `/space` **zip** Lambda (agent brain as a function) + the Spaces app at
`witbitz-spaces.pages.dev`. The turn core, transport (mk-commit), content-blind poll, delegated authority, and
per-member signed identity (solo) are all shipped. Deferred: real-time away-notification (closed app);
compaction of long ledgers; the couple (2-of-2) signed path.
