# How Ziran Uses Spaces The reference for the shipped behavior — what Ziran actually writes, reads, and enforces on [AT Proto Spaces](https://atproto.com/blog/atproto-spaces-alpha). The design rationale and build history live in `SYNC-SPACES.md`; this document describes the system as it runs. Spaces are the **primary sharing method**: `ZIRAN_SYNC` unset means the Spaces provider; the parked relay+MLS stack sits behind `ZIRAN_SYNC=relay`. Everything below is enforced by tests: the scope grants run through the PDS's own matcher (`src/sync/spaces/scopes.test.ts`), the flows run against an in-memory network (`client.test.ts`, `provider.test.ts`), and the whole loop has been verified live against `pds.ziran.space` (spike 22/22 + a three-device, two-account provider smoke, 2026-08-30). ## The shape of a Ziran space A space is an access-control boundary at `at://{ownerDid}/space/{type}/{skey}` containing **one repo per member, each hosted on that member's own PDS**. Members read everything in the space and write only to their own repo. Ziran defines two space types (published as lexicons under the `lexicons.pds.ziran.space` authority, resolvable via the `_lexicon.ziran.space` DNS TXT record): - **`space.ziran.space`** — the core type. One space = one shared context of live documents, member-list policy, owned by its creator. Renamed from `space.ziran.workspace` on 2026-08-30; nothing real ever shipped under the old name (the OAuth scope bug blocked every go-live), so there is no migration — the provider drops legacy registry entries on load. - **`space.ziran.inbox`** — every signed-in identity keeps one at skey `self` with **public policy**, so anyone can construct the ref from a DID alone, mint a credential, and deliver an invitation into their own repo there. It carries only `space.ziran.invite` records. ### Collections in a `space.ziran.space` | Collection | rkey | Written by | Content | |---|---|---|---| | `space.ziran.info` | `self` | space owner | `{name, createdAt}` — the human name (simplespace has no name field) | | `space.ziran.doc` | TID | doc creator | partial manifest: `{name, model, roles, createdAt}` | | `space.ziran.op` | TID (client-assigned) | any member | `{doc, parents: [{author, rkey}], bytes}` — one tp-sync frame | | `space.ziran.checkpoint` | doc rkey | any member | atile-style manifest + `frontier: {did → rev}` | | `space.ziran.presence` | doc rkey | any member | `{doc, at}` — refreshed while the doc is open | Doc discovery in a space = the union of `space.ziran.doc` records across member repos. The doc record's at-URI is stamped into the on-disk tile manifest (`at` field) — the durable link between a `.tile` file and its online identity. ## Sign-in and permissions The OAuth scope lives in **`src/sync/spaces/scope.ts`** — the single source imported by the sign-in (`atsession.ts`), the provider, and the scope test, so they cannot drift. It is: ``` atproto include:space.ziran.permissions space:*?authority=*&action=read_self ``` - `include:space.ziran.permissions` pulls in the published permission set: full read/write/manage on `space.ziran.space` (all five collections), invite delivery on `space.ziran.inbox`, and blob upload. - `space:*?authority=*&action=read_self` is requested **literally** because a permission set can never grant a wildcard space type (the server's authority rule silently drops such entries), and `listSpaces` — "which spaces do I write into?" — needs exactly that wildcard. `read_self` lists space refs only; it grants no document content. Grants are materialized into the token **at consent time**, so any change to the scope or the published set bumps `SCOPE_VERSION` in `atsession.ts` (currently 5, for the type rename): stored sessions under an older version are retired at launch and the UI explains why a fresh sign-in is needed. Dev/test sessions bypass OAuth entirely via `ZIRAN_SPACES_HANDLE/_PASSWORD/ _PDS` password credentials (the space endpoints accept legacy sessions). ## The flows ### Going live 1. Pick an existing space you're a member of, or name a new one — `createSpace('space.ziran.space', memberList)` + a `space.ziran.info` record with the name. 2. Write the `space.ziran.doc` record (name, model id, role assignments); stamp its at-URI into the tile manifest. 3. Publish the **mandatory go-live checkpoint** so others can instantiate. ### Syncing Pull-only, per member repo — there is no push a desktop app can use: - `listSpaces` sweep every ~60 s (multi-device discovery), `listRepos` + `listRepoOps` per space every ~2 s while one of its docs is open, ~30 s otherwise, and immediately after an own write. - Outgoing frames coalesce ~300 ms (max 2.5 s under sustained typing) into one `applyWrites` batch with **client-assigned TIDs**, so intra-batch parent chains are correct in the records as written. - Delivery is **causal, not total**: each op names its parents; an op waits until its parents are settled; concurrent siblings deliver in deterministic `(rev, author)` order. Dedup is per-doc rev watermarks (revs are the only per-repo order — one DID can be many devices) plus an own-echo list. Own frames come back through the same delivery path as everyone else's: one tile code path. ### Checkpoints (rare + lazy) Full state so joiners never replay from zero. Triggers: go-live (mandatory), ~500 own ops or ~512 KB since the last own checkpoint (at most one per hour), or an explicit publish. Following `@dasl/atile`: every tile resource (self-storage data included) uploads as a blob (skipped when the CID is unchanged — blob CIDs are the same CIDv1/raw/sha-256 as tile resource CIDs); the record carries the manifest with blob refs plus the rev `frontier` it represents. Ops are **never deleted** — a doc is always rebuildable from the first checkpoint plus the full op history. ### Adopting (second device, or an invitee) Newest checkpoint from any member → download blobs → verify CIDs → assemble the `.tile` via the normal writer → open → seed the dedup lines from the checkpoint frontier → rewind the space cursors and let the ordinary poll replay everything past the frontier. ### Invitations Being added to a space is **not discoverable** (`addMember` does not notify; `listSpaces` only shows repos you've written), so invites travel two co-equal paths: 1. The owner calls `addMember`, records the invitee's per-doc role (doc author only), and writes a `space.ziran.invite` into the invitee's public inbox space. The invitee's ~60 s inbox poll surfaces it in the invitation strip; accept = adopt. Dismissals are app-local (the record belongs to the inviter). 2. A `ziran://space?ref=…&doc=…` link (shown in the panel, never auto-copied) adopts directly — it also covers invitees who had no inbox yet (membership is granted regardless; the invite reports `pending`). **Membership is per-space, not per-doc** — invitees see every document in the space — and **only the space owner can invite** (simplespace restriction). The connections panel says both, verbatim. ### Presence While a shared doc is open, the provider refreshes a `space.ziran.presence` record (~60 s); peers read the beacons off the oplog and show a member as present while their timestamp is under ~150 s. That is the entire ephemeral story — no cursor streaming, `sendEphemeral` is a no-op. ## What's enforced where | Concern | Enforced by | |---|---| | Read access, membership | protocol (credential mint + credentialed reads) | | Write isolation (own repo only) | protocol | | Invite rights (owner-only) | protocol (`manage=update`) | | Role assignments | app — the doc record's author is the sole authority | | Role meaning (who may edit, etc.) | the tile, from frame attribution | Honest caveat: there is no protocol write-gate below space membership — a rogue member's records reach honest clients, which ignore them deterministically. ## Trust and privacy (alpha posture, chosen deliberately) - **No E2EE**: PDS operators can read document content. The trade is credible exit — every member's contribution lives in their own repo under a standard protocol, with no Ziran service in the critical path. The relay+MLS stack is the recorded road back. - **Public inboxes are enumerable** — invitations leak the collaboration graph beyond "trust your PDS operator". Revisit via `appAccess: allowList` or the E2EE return. - Alpha data loss is acceptable; ops-never-deleted plus checkpoints make documents rebuildable, not immortal. ## Local state and failure behavior - `sync/spaces.json` (app data): known spaces, per-doc delivery cursors, checkpoint bookkeeping. Everything except localId/path bindings and acks is reconstructible from the network. - Space-endpoint refusals are classified (`unsupported` / `permission` / `other`) so the panel can say the true thing: "your PDS doesn't support live documents yet" vs "sign out and back in to grant permission" vs an actual reachability problem — with the raw error selectable and copyable. A refused call never tears down a working session. ## Verifying - `deno task test:sync` — the full suite (scope grants through the server's own matcher, client + provider against the in-memory network, tile convergence proofs, and the parked relay stack's suites). - `SPIKE_INVITE= deno run -A tools/spaces-spike.ts https://pds.ziran.space https://pds.ziran.space https://plc.directory` — 22 protocol checks against the live PDS. - `deno task lex:publish` — idempotent lexicon publish + DNS check.