Understanding any/The tech space

The tech space

Every account has exactly one tech space: a space derived from the account keys, with an owner-only ACL, that syncs only between that account's own devices. It is where any keeps everything that is about the account rather than about a shared space — starting with the list of spaces itself.

What it is

  • Derived. Its id is a pure function of the account key; every device of the account computes the same id and re-creates it on first login. Its header carries spaceType: any.techspace.
  • Owner-only. The ACL has one member and the network refuses any further ACL record. Nothing in it can be shared; there is no such thing as a shared account.
  • Unlisted. It never appears in GET /v1/spaces, and its datasets are read through dedicated endpoints. Its id (techSpaceId on GET /v1/account) is a :spaceId only on a closed set of per-space routes — account-level bundles and the records and types on their roots; every other per-space route answers 405 space.unsupported. It uses the same record CRDT as every other space, so writes to it sync, merge and converge like any other data.
  • First. It loads before regular spaces at boot, because the space list comes from it.

What it holds

The tech space carries one derived index object whose datasets are the account's system data:

Dataset One row per Read through Written by
spaces space the account participates in GET /v1/spaces, POST /v1/spaces/query[/subscribe] the SDK on create / join / delete, and the mirrors below
profile the account GET /v1/account, POST /v1/spaces/query with "dataset": "profile" PUT /v1/account/metadata
devices device (row id = peer id) GET /v1/devices, POST /v1/devices/query[/subscribe] PUT /v1/devices/me, POST /v1/devices/activate, engine boot
identities account identity ever encountered GET /v1/identities[/…] the SDK, from ACL records, one-to-one invites and the identity repository
inboxCursor the account — the coordinator-inbox receiver (one-to-one and direct-add invites)
crdtVersion the account GET /v1/health (crdtVersion) every release, on open (Versioning)
bundles account-level bundle, such as favourites GET /v1/spaces/<techSpaceId>/bundles POST /v1/spaces/<techSpaceId>/bundles (Bundles)

A spaces row is the account's view of a space:

{ "id": "bafy…", "type": "any.space", "name": "Team", "icon": "…",
  "remoteStatus": "active",
  "createdAt": { "$date": "2026-08-01T09:00:00.000Z" },
  "ownRole": "writer", "push": { "spaceKey": "…", "encKey": "…", "encKeyId": "…" },
  "settings": { "notifyMode": "mentions" }, "derived": false }

Fields mix scopes on purpose: type, name, description, icon, remoteStatus, settings, derived are synced across the account's devices, so a join pending (joining), a decline or a delete observed on one device converges the rest; createdAt is derived — stamped from the change that added the row (create for the author, join for a joiner), the same on every device; ownRole and push are local — each device derives them from the same converged inputs, so replicating them would only add lag — and so is localStatus (absent while the space is simply active), because a space offloaded on one device must stay loaded on another. Raw rows withhold private guest and invite keys.

The space list is a view

GET /v1/spaces maps spaces rows into SpaceInfo, filtering to active rows by default; GET /v1/spaces?status=one_to_one_pending or status=deleted selects the others. POST /v1/spaces/query and POST /v1/spaces/query/subscribe expose the raw rows through the same windowed primitive every dataset uses:

curl -N -X POST http://127.0.0.1:7001/v1/spaces/query/subscribe \
  -H 'content-type: application/json' \
  -d '{"sort":["-createdAt"],"limit":50}'
# event: ready → snapshot{records} → changes[{added,updated,removed}] …

Because the list is a dataset, everything that changes a row arrives as a row update on that stream: a join completing on another device, a rename, a role change, a push-key rotation, a settings edit (Live space list). The dataset field of these two endpoints accepts only spaces and profile — the identities rows carry each contact's profile-decryption key, and are read through GET /v1/identities where that key is stripped.

Mirrors: how rows stay current

Several row fields are derived from other places and mirrored in by SDK watchers on each device:

Field Source of truth Mirror
name, description, icon the target space's own spaceIndex object — encrypted in-space CRDT data, so every member sees a rename each device's indexer hook writes the converged value onto the account's row after PATCH /v1/spaces/:id or a peer's edit, skipped when already equal
ownRole the space's ACL the ACL mirror watcher, once at space load and once per applied ACL record
push.{spaceKey,encKey,encKeyId} ACL key material the same watcher; encKey rotates with the read key
type the space header backfilled once, set-once, on the first successful load of a row registered before the header was readable

A mirror is asynchronous, which shows up in one place: an immediate re-read after PATCH /v1/spaces/:id can briefly return the old name. Subscribe to the space list or to the spaceIndex object if you need the moment it lands.

Account-scoped values

A property declared scope: account is shared across the account's devices but invisible to other members — a personal flag on a shared object, or a chat's notifyMode. The tech space is its transport: for every target space the account is in, it holds one derived carrier object (account_values dataset, one record per (objectId, dataset, recordId)), and a per-device mirror replays converged carrier values into the target rows at their normal paths, stamped with the tech tree's versions.

The write path is the ordinary one — POST …/types/:t/properties with "scope": "account", then a value write — and reads see the value at its usual path. Deleting the object tombstones its carrier records; leaving or deleting the space drops the carrier (System fields). Values of scope: local take the shorter route: written straight into the device's row, no DAG, no tech space. Dataset record fields can declare account but are not writable at that scope.

Devices and the account's own state

The devices dataset is what gives the account a notion of "my devices": one row per peer id with name, OS, version, installed apps and active-app claims, all synced through the tech space and nowhere else. The self row is upserted at engine boot; election of the active device for an app slug is computed by readers from the converged claim data, never from version ids (Devices).

Per-space settings (the notifyMode for push, for example) live on the spaces row for the same reason — they are the account's preference about a space, not the space's data — and so sync to the account's other devices without ever reaching other members (Push).

Why account-only

Everything here is about you: which spaces you are in, what you call your devices, how you want to be notified, what you flagged for yourself. Keeping that in a space only your keys can open gives three properties at once:

  • Multi-device for free. A second device restored from the mnemonic derives the same tech space, pulls it first, and immediately knows every space to join, every device sibling, and every account-scoped value — before any shared space has loaded.
  • Privacy by construction. Other members never see your space list, your device names, your read markers or your settings, because those rows never enter a shared tree.
  • One CRDT. Account state converges by the same rules as everything else; there is no separate "preferences sync" with its own conflict story.

Why it matters. A hosted backend stores your account profile, device list and per-user preferences in tables only the server can join. any stores them in a space only you can decrypt, replicated by the same protocol as your documents — so "which devices are mine" is a query, and answering it needs no server at all.

Further reading

  • Accounts — mnemonic, account id, restoring a device.
  • Spaces — the SpaceInfo shape and status vocabulary.
  • Identities — the encrypted profile directory.