This repository has no description
ziran docs SPACES.md
9.7 kB
Markdown
at main

How Ziran Uses Spaces #

The reference for the shipped behavior — what Ziran actually writes, reads, and enforces on AT Proto Spaces. 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=<code> 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.