Source: https://docs.any.org/collaboration/bundles.html # 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: when two devices install concurrently it converges on one deterministic winner and lists the rest as losers. Clients register their own bundles; the server enforces the timing of deleting a losing root, and owns one id namespace — ids under `system:` are installed only through its embedded [catalog](https://docs.any.org/collaboration/bundles.md.txt#the-usecase-catalog) (`409 bundle.reserved` on a client ensure). ## Endpoints ``` POST /v1/spaces/:spaceId/bundles → 200 { bundle, installed } GET /v1/spaces/:spaceId/bundles → 200 { bundles: [...], synced } GET /v1/spaces/:spaceId/bundles/:bundleId → 200 { bundle, synced } POST /v1/spaces/:spaceId/bundles/:bundleId/resolve → 204 POST /v1/spaces/:spaceId/bundles/:bundleId/children → 200 { objectId } ``` Reads answer after the registry's convergence wait (below); `synced: true` means it converged, so an absent bundle is definitively not installed, and `false` (a cold offline device) means absence is provisional. Bundle ids carry a slash — the version suffix is part of the id (`favorites/v1`), and ids are permanent, so a successor install takes a new one. In a path segment the slash is percent-encoded: `/bundles/favorites%2Fv1`. Request bodies take the id verbatim. ## Ensure: adopt or install ```bash curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles \ -d '{"id": "notes/v1", "name": "Notes", "xKey": "notes", "hidden": true, "parts": [{"key": "body", "datasets": [{"module": "editor", "shared": true}]}]}' ``` ```json { "bundle": { "id": "notes/v1", "rootId": "bafy…", "roots": ["bafy…"], "losers": [], "derived": false }, "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 | | `rootType` | the root's one type; must exist in the space. **Required when the body declares nothing** (`400 request.missing_field`; `page` for a plain root) and refused next to a declaration — a declaring root's type slot holds its own marker | | `rootCollections` | collections the root is filed under; each must exist in the space; ≤32 | | `rootProperties` | initial property values keyed by owner — `rootType` or one of the collections — validated against their descriptors; ≤64 KiB | | `parts` | parts of the root type (`typeId = rootId`) — the [modules](https://docs.any.org/types/index.md.txt) and records datasets (`_`) objects of that type hold; ≤32; the same draft shape as `POST …/types/:typeId/parts`. Refused with `collection` | | `properties` | property definitions on the root definition, each with an `xKey` its id derives from — two devices installing apart mint one column per handle; ≤64; the same draft shape as `POST …/types/:typeId/properties` | | `xKey` | the definition's handle, unique among the space's types **and** collections; `409 type.xkey_conflict` | | `collection` | `true` declares a **collection** instead of a type: `properties` are its columns, `parts` and `layout` are refused | | `layout` | the root type's rendering slice, as on `POST …/types`; refused with `collection` | | `hidden` | keeps the root definition out of `GET …/types` / `GET …/collections` — ask for it when the root only hosts its bundle's own records | | `derived` | install on the root derived from the bundle id (below) | A bundle may declare a definition on its root — `parts`, `properties` or an `xKey` make the root a **type** (`any.type` holds the `__type__` marker, `typeId = rootId`), and `collection: true` makes it a **collection** (`__collection__`, `collectionId = rootId`). `layout` and `hidden` describe the definition and are `400 request.invalid_field` without one. A part naming a module reserved to the server (`chat`) is `400 dataset.module_reserved`. **A declaring root hosts itself.** It holds its own `.` values and the records of its own datasets with no flag and no self-membership — the marker sits in `any.type`, so the root never matches `{"any.type": ""}` or `{"any.collections": ""}` and member queries need no exclusion. One object is the definition, its own first user, and — filed under `miniapp` through `rootCollections` — the sidebar entry. With a winner already registered, Ensure **adopts** it and `installed` is `false`. For a reader or guest that is a pure read, so they resolve an install they could not create; a writer's adopt also fills in declarations the root lacks (a property handle), never patching what it has. Otherwise the server creates the root with the requested type, collections and properties, registers it, and replies `installed: true`. That path is a write, so a member without write permission gets `403`; use `GET …/bundles/:bundleId` instead. Definition existence and property formats are checked *before* the root is created, so a rejected request (`400 type.not_found`, `400 collection.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](https://docs.any.org/collaboration/one-to-one.md.txt): 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 usecase catalog The well-known apps that ship with the product — the wiki, the Collections app (the `collections` usecase: browse and manage objects by type), journal, meetings, people, CRM, the general chat — are bundles in a catalog embedded in the server, under `system:/v` ids. A **usecase** is a set of those bundles plus the usecases it `requires`. The catalog declares every type, collection, property and dataset its apps use, so each client, device and agent that sets a usecase up resolves the same ids instead of minting its own. Each catalog bundle declares a `type` (parts, layout, columns) **or** a `collection` (columns only), never both. The wiki is a collection — a wiki page keeps its own type and is filed under it — and so are the seven role facets `contact`, `investor`, `customer`, `partner`, `vendor`, `cofounder` and `candidate`: a contact is `type: person` filed under the contact collection, with each owner's values in its own namespace. `person`, `organization`, `deal`, `meeting`, `journal` and the general chat are types. ``` GET /v1/catalog → 200 { usecases: [...] } GET /v1/catalog/:usecaseId → 200 usecase 404 catalog.not_found POST /v1/catalog/:usecaseId/setup → 200 { usecase, bundles: [...] } body { spaceId } ``` Setup walks the dependency closure (dependencies first) against one convergence wait and runs Ensure's adopt-or-install per bundle; it is idempotent, and a failure names the step in `details.bundleId` while the steps before it stand, so the next call resumes. Each reply entry carries the registry row (`bundle`), `installed`, and — for a bundle that declares a definition — `typeId` or `collectionId` (the root id, whichever kind it declares) plus `properties` (xKey → property id). An install whose `xKey` a type or collection in the space already holds is `409 type.xkey_conflict` with `details.existingTypeId` or `details.existingCollectionId`. Which usecases a space has reads off `GET …/bundles`. CLI: `any catalog list | get | setup`. ### The general chat A space's chat is the server's: the `chat` module is reserved, so no client bundle may declare a chat part (`400 dataset.module_reserved`). The catalog's `general-chat` usecase installs the one chat, a derived root: ```bash curl -s -X POST http://127.0.0.1:7001/v1/catalog/general-chat/setup \ -d '{"spaceId": "'$SPACE'"}' ``` Use the entry's `bundle.rootId` as the [chat](https://docs.any.org/types/chat.md.txt) object. Every client that runs the setup lands on the same root, on both sides of a 1-1 too, and no other object may carry its type. ## Children ```bash curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/notes%2Fv1/children \ -d '{"seed": "settings", "type": "page", "collections": ["'$WIKI'"]}' # → { "objectId": "bafy…" } ``` A child is a setup object derived under the bundle's current winner: `type` is required and set on first materialization, `collections` the child lacks are added on every call. It is 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 ```bash curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/bundles curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/notes%2Fv1 # 404 bundle.not_found ``` Rows are also readable through the ordinary dataset surface — `POST /v1/spaces/:spaceId/query` with `{"objectId": "", "dataset": "bundles"}` — which is how a client [subscribes](https://docs.any.org/realtime/subscribe.md.txt) 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: ```bash curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/notes%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 | | 409 | `bundle.reserved` | a client ensure with a `system:` id | | 409 | `type.xkey_conflict` | an install whose `xKey` another type or collection in the space already holds (`details.existingTypeId` / `details.existingCollectionId`) | | 400 | `dataset.module_reserved` | a part naming a module reserved to the server | | 400 | `request.missing_field` | a bundle that declares nothing and has no `rootType` | | 400 | `type.not_found` | `rootType` does not exist in the space | | 400 | `type.not_a_type` | `rootType` names a collection — a collection goes in `rootCollections` | | 400 | `type.reserved_carrier` | `rootType` names a type only its own root may carry (the general chat's) | | 400 | `collection.not_found` | a `rootCollections` entry or a `rootProperties` owner does not exist in the space | | 400 | `collection.not_a_collection` | a `rootCollections` entry names a type, or a `rootProperties` owner is a type other than `rootType` | | 400 | `property.format_violation` | a `rootProperties` value does not fit its property's descriptor | 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, for each well-known [derived space](https://docs.any.org/collaboration/derived-spaces.md.txt) the account has, the server waits for the space list and that space's index to converge and reads its registry, so a client ensuring right after a restore meets the converged registry instead of an empty one. It installs nothing and deletes nothing itself. ## Account-level bundles Account-private data — favourites, pinned items, personal settings — lives in bundles on the account's **tech space**, whose id `GET /v1/account` returns as `techSpaceId`. Ensure, list, get and resolve work there with two differences: the bundle must declare a definition (`parts`, `properties` or an `xKey`, type or collection), and `rootType`, `rootCollections`, `rootProperties` and children are refused. The records live on the root that declares them — a definition hosts itself — and go through `query`, `modify` and `upsert` like in any space; `DELETE /v1/spaces/:techSpaceId/objects/:rootId` uninstalls a created root. Everything else on the tech space answers `405 space.unsupported`.