docs

Collaboration

Bundles

A bundle is one thing installed into a space — a chat, an app's setup, a marketplace package — registered as one root object under a permanent id, with every setup object derived from that root. One converged id names the whole install, so every member and device reads and writes the same objects instead of each creating their own.

Why a registry

Clients that each create a chat object if none exists leave a space with two or three parallel chats, most visibly in a one-to-one. The registry (the bundles dataset on the space's index object) is the convergence point: the server keeps no catalog and installs nothing on its own; what it owns is picking the winner when two devices install concurrently, and refusing to delete a losing root before it has stopped arriving.

Endpoints

POST   /v1/spaces/:spaceId/bundles                        → 200 { bundle, installed }
GET    /v1/spaces/:spaceId/bundles                        → 200 { bundles: [...] }
GET    /v1/spaces/:spaceId/bundles/:bundleId              → 200 Bundle
POST   /v1/spaces/:spaceId/bundles/:bundleId/resolve      → 204
POST   /v1/spaces/:spaceId/bundles/:bundleId/children     → 200 { objectId }

Bundle ids carry a slash — the version suffix is part of the id (general-chat/v1), and ids are permanent, so a successor install takes a new one. In a path segment the slash is percent-encoded: /bundles/general-chat%2Fv1. Request bodies take the id verbatim.

Ensure: adopt or install

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles \
  -d '{"id": "general-chat/v1", "name": "General", "rootTypes": ["chat"], "derived": true}'
{ "bundle": { "id": "general-chat/v1", "rootId": "bafy…", "roots": ["bafy…"], "losers": [], "derived": true },
  "installed": true }
Body field Meaning
id the whole identity — an app slug, a marketplace id, a versioned convention; ≤256 B
name stamped as any.name on the root; ≤1024 B
rootTypes types attached to the root; must exist in the space; ≤32
rootProperties initial property values, validated against their formats; ≤64 KiB
derived install on the root derived from the bundle id (below)

With a winner already registered, Ensure is a pure read — nothing is written, a reader or guest can resolve an install they could not create, and installed is false. Otherwise the server creates the root with the requested types and properties, registers it in one change, and replies installed: true. That path is a write, so a member without write permission gets 403; use GET …/bundles/:bundleId instead. Type existence and property formats are checked before the root is created, so a rejected request (400 type.not_found, 400 property.format_violation) never leaves an orphan.

The convergence gate

Installing a created root first waits for the registry to converge — bounded at 30 s, cut to 3 s when no peer is connected. A member that ensures against state it has not synced would read "nothing installed" and mint a root competing with the one already out there. Adopting never waits; a read cannot fork anything. A space never set up converges to an empty registry, which is a valid answer.

When the wait cannot complete, who is asking decides: the space's owner installs anyway (only its own devices could compete, and the registry converges those), so an offline owner is never blocked. Any other member is refused with 409 bundle.not_ready and retries when the network is back. A winner whose tree has not reached this device is likewise 409 bundle.not_ready — its id would reject every write.

Note. rootId is provisional until the space syncs. Two devices that install while genuinely apart each register a root; the registry converges on one winner and the other lands in losers. Re-read after sync.

Derived roots

"derived": true installs the bundle on the root derived from its id. That id is a pure function of (space, bundle id) — the derived root change carries no identity, signature or timestamp — so every device and every member computes it offline. Nothing can fork: each side registers the same id and losers stays empty.

This is the answer for a space's chat and the only workable one for a one-to-one: its ACL owner is a synthetic key nobody holds, both participants are writers, neither can take the owner escape, and a created root leaves both refused until they converge — which never happens while apart. With a derived root each side installs immediately and the two copies merge like any other CRDT tree.

The price is permanence, in two directions:

  • No uninstall. A derived tree cannot be deleted, so the bundle id stays bound to that root for the space's lifetime. Use it for setups that must exist on both sides of a partition, not for anything a user may remove.
  • No migration. A bundle already installed on a created root is adopted, not moved: installed: false, derived: false, the created rootId. Moving content between roots is the client's decision.

If a created and a derived root are both claimed for one id, the derived one wins on every device and the created one becomes an ordinary loser. A derived install runs the same convergence wait but installs when it expires; a blind claim can therefore demote a created install this device had not seen — nothing is destroyed, but for unmergeable content such as chat the pointer moving amounts to the same thing. That is the deliberate trade that lets an offline 1-1 have a chat at all.

Why it matters. A hosted chat service allocates one channel id and everyone uses it. Two encrypted peers that have never spoken cannot ask anyone for an id — but they can both compute one. Derivation replaces the allocator.

The general-chat convention

A space's "general" chat is not a server concept; clients register it:

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles \
  -d '{"id": "general-chat/v1", "rootTypes": ["chat"], "derived": true}'

Use the returned rootId as the chat object. Different clients that agree on the id agree on the chat.

Children

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/general-chat%2Fv1/children \
  -d '{"seed": "settings", "types": ["page"]}'
# → { "objectId": "bafy…" }

A child is a setup object derived under the bundle's current winner: deterministic per (space, root, seed), materialized on the first call, the same id on every device — a restored device reaches the whole install from the winner alone. Seeds are permanent and ≤256 B. Under a created root the child binds to the parent's tree and cascade-deletes with it; on a member whose copy of the winner has not landed, the call is 409 bundle.not_ready. Under a derived root the child binds by seed instead — same ids everywhere, and the cascade is moot on a root that can never be deleted.

Reads

curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/bundles
curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/general-chat%2Fv1     # 404 bundle.not_found

Rows are also readable through the ordinary dataset surface — POST /v1/spaces/:spaceId/query with {"objectId": "<spaceIndexObjectId>", "dataset": "bundles"} — which is how a client subscribes to live conflict updates. That path is read-only; no client can forge a claim. Raw rows carry the stored rootId register and no derived field — the derived-root verdict is applied by GET …/bundles[/:bundleId], so read those when a bundle may be derived.

Losers and resolve

losers is the live conflict set: claimed roots that are neither the winner nor already deleted. Non-empty means two devices installed concurrently and the loser may hold real content. Cleanup is the client's call — merge what matters out of the losing root and its children, then:

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/general-chat%2Fv1/resolve \
  -d '{"loserRootId": "bafy…"}'
# → 204

The server never merges; it enforces timing. A losing root arrives change by change, and a merge made from a half-arrived tree is a half-merge, so resolve is refused until the SDK reports the root fully synced and it has been observed as a loser for a five-minute grace period. The clock starts when the conflict first became visible on this device (any GET …/bundles[/:id] or the boot pass counts), not at the first resolve call. After a timing refusal the server keeps retrying in the background — in memory, dropped on restart — so clients retry too.

Status Code When
409 bundle.not_ready registry not converged (non-owner), or the winner's tree has not arrived
409 bundle.loser_not_ready loser not fully synced, or within the grace period — retry
409 bundle.not_loser the winner, or a root never claimed for this bundle
404 bundle.not_found unknown bundle id on GET / resolve / children
400 type.not_found a rootTypes entry does not exist in the space
400 property.format_violation a rootProperties value fails its declared format

Resolving an already-resolved root returns 204 — the call is idempotent.

Agreeing who installs

Nothing stops two members from ensuring the same bundle; the registry converges and reports a loser. To avoid the conflict entirely, either agree on one installer out of band, or ask for a derived root — the id both would compute anyway. Bundle records are permanent and roots only grows, so treat ids as a small fixed vocabulary, not a scratch namespace.

On boot the server converges the space list and projects the index of the account's well-known derived spaces, so a client ensuring right after a restore meets the converged registry instead of an empty one. It installs nothing and deletes nothing itself.