# 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 }` where `tile` is the manifest with every resource uploaded as a repo blob and `src` replaced by a blob ref (checkpoint shape — see src/sync/spaces/checkpoint.ts), and `cid` is the DRISL CID of that manifest. `prev` chains to the version this publish replaced. - `kind` is `model` iff the manifest has a `model` field; otherwise `app`. - Publication stamps the identity into the manifest: `id` (and `model.id`) become `/`; rendered everywhere as `@handle/`. - 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.install` record 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=` (publisher profile + their tiles), `…&tile=` (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 `; anywhere: `deno install -gAf --name ziran /src/cli/ziran.ts`. - `ziran publish [--name slug]` — publish under your identity (slug defaults to the file stem); re-publishing a name chains versions. - `ziran unpublish ` — 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.