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 fromspace.ziran.workspaceon 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 skeyselfwith 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 onlyspace.ziran.inviterecords.
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.permissionspulls in the published permission set: full read/write/manage onspace.ziran.space(all five collections), invite delivery onspace.ziran.inbox, and blob upload.space:*?authority=*&action=read_selfis requested literally because a permission set can never grant a wildcard space type (the server's authority rule silently drops such entries), andlistSpaces— "which spaces do I write into?" — needs exactly that wildcard.read_selflists 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 #
- Pick an existing space you're a member of, or name a new one —
createSpace('space.ziran.space', memberList)+ aspace.ziran.inforecord with the name. - Write the
space.ziran.docrecord (name, model id, role assignments); stamp its at-URI into the tile manifest. - 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:
listSpacessweep every ~60 s (multi-device discovery),listRepos+listRepoOpsper 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
applyWritesbatch 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:
- The owner calls
addMember, records the invitee's per-doc role (doc author only), and writes aspace.ziran.inviteinto 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). - 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 reportspending).
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: allowListor 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.