The Ziran Store & the doc = space model #
As shipped 2026-09-05. Companion to docs/SPACES.md (sync) and docs/WEB-APP.md.
Doc = space #
Every document is its own space (space.ziran.space), named after the
document, holding exactly one space.ziran.doc record (plus checkpoint,
ops, presence). Consequences:
- The Docs list is "all doc-spaces I can see" (via listSpaces + polling), plus any legacy docs still in the home space.
- Inviting someone to a document IS inviting them to its space — invitations say the document's name; there is no separate space to explain.
- Deleting a document you own deletes its space (
deleteSpace); the provider's poll loop forgets not-found spaces instead of erroring. - "Go live" on a desktop/home doc creates the doc's own space with no
picker (
share()defaults the space name to the doc name). - Multi-doc spaces remain readable (legacy rows render per-doc); they will return as a first-class concept later.
The home space remains as the self space: install records
(space.ziran.install) and legacy docs. Browser tabs still hold nothing
durable.
Store data model #
Publishing writes to the publisher's ordinary AT repo (public, indexable, no space credential to read):
- Collection
space.ziran.tile, rkey = the store name (slug). - Record:
{ $type, name, title, summary?, kind: 'model'|'app', tile, cid, prev?, createdAt }wheretileis the manifest with every resource uploaded as a repo blob andsrcreplaced by a blob ref (checkpoint shape — see src/sync/spaces/checkpoint.ts), andcidis the DRISL CID of that manifest.prevchains to the version this publish replaced. kindismodeliff the manifest has amodelfield; otherwiseapp.- Publication stamps the identity into the manifest:
id(andmodel.id) become<did>/<name>; rendered everywhere as@handle/<name>. - Screenshots: manifest
screenshots: [{ src }]entries (tile resources) surface on the store card. - Reads are unauthenticated XRPC (
com.atproto.repo.listRecords/getRecord,com.atproto.sync.getBlob) against the publisher's PDS. - Install = a
space.ziran.installrecord in the installer's home space (publisher DID, name, kind, title, icon CID, pds, order) — the sidebar reads it on any device. - Instantiating fetches every blob, verifies CIDs, and assembles with the normal tile writer (src/store/store.ts).
OAuth grants (permission set v-current, SCOPE_VERSION 8): home space gains
space.ziran.install; a repo permission grants create/update/delete on
space.ziran.tile. After changing lexicons/space/ziran/permissions.json run
deno task lex:publish and sign in again everywhere.
The examples are published under the Ziran (lexicon-authority) account:
deno task store:publish-examples (same credentials as lex:publish). The
web app's featured shelf lists that account (FEATURED_PUBLISHERS in
src/web/webbackend.ts).
Root UI #
Three tabs + an always-present right sidebar (src/components/):
- Docs (
zn-docs-table) — sortable, filterable table: icon, name, creator, contributors, created, updated, liveness, context menu. - Store (
zn-store) — cards; routes ride the fragment:#view=store,…&pub=<did|@handle>(publisher profile + their tiles),…&tile=<name>(detail with screenshots + Install). - Tools (
zn-tools) — Publish (working: pick a .tile, name it, publish under your identity), Dev mode and Import (skeletal). - Sidebar (
zn-sidebar) — accordion of Models and Apps: installed store tiles (+ built-in starters during the transition), click to open/instantiate, Edit mode to reorder/remove, store links when empty. - Search — the header field searches everything and shows categorised results (Documents / Models & apps / Store) in place of the active tab.
The index #
index.ziran.space (deploy/index, deno task deploy:index) answers "what
is published, network-wide": a poll-based crawl of the PDS — where every
ziran account lives during the alpha — diffing com.atproto.sync.listRepos
revs (paginated) and re-reading space.ziran.tile only for changed repos,
every 60s. Memory-only state (a restart re-crawls in seconds); public data
only. API: GET /tiles?q=&publisher=&kind=, GET /healthz; CORS open.
The crawl/query core is src/store/indexer.ts, tested in the suite and
staged verbatim into the service (deno task stage:index). The web app's
shelf and search use the index and fall back to the featured publishers'
PDSes when it doesn't answer; publisher and detail pages always read the
publisher's PDS directly (canonical). When accounts spread across PDSes,
the growth path is feeding this same core from a firehose consumer (Tap or
kin) instead of the poll.
Timestamps come from the records themselves: every ziran record carries
createdAt (ops included), presence carries at; a doc's "last update" is
the freshest datestamp its space's records show. Nothing infers dates from
TIDs.
The CLI #
ziran (src/cli/ziran.ts) — atile's sibling for the store. In-repo:
deno task ziran <command>; anywhere:
deno install -gAf --name ziran <repo>/src/cli/ziran.ts.
ziran publish <file.tile> [--name slug]— publish under your identity (slug defaults to the file stem); re-publishing a name chains versions.ziran unpublish <name>— retract.ziran list [publisher]— what a publisher offers (default: you).
Identity: --as/--password/--pds flags, then ZIRAN_HANDLE/ZIRAN_PASSWORD/
ZIRAN_PDS, then STORE_PUBLISHER_*, then .env.store-publisher (cwd or repo
root). An app password works; the PDS resolves from the handle. More
commands land here as they exist.
Tests: src/store/store.test.ts (publish → list → instantiate → version chain), src/store/indexer.test.ts (crawl → incremental re-crawl → query), src/cli/cli.test.ts (the CLI as a real subprocess: publish/list/version/ unpublish), scopes.test.ts (grants), plus the provider/webhost suites.