Versioning

An app's versions share one history. You can push a commit, move a branch or tag, merge, compare changes, and collect unused objects with Git-like commands.

Docs menu

Everything you ship goes through that history. substrio push writes a commit, and substrio promote moves a name. Numbered versions are a view of the same objects and references used by branches, tags, merges, logs, and garbage collection.

The commands use familiar Git verbs such as branch, log, tag, merge, diff, and gc. They operate on the app rather than a working copy.

The model: objects, refs, history

The history consists of objects, refs, and commits.

Objects are immutable and content-addressed. Exactly three kinds:

Kind What it is Content digest
blob one unit's bytes (a function's source, a site file, a rules document) GxHash-128 of the bytes
tree a sorted list of (name, kind, 32-byte hash) GxHash-128 of its canonical binary encoding
version a root tree, 0-2 parents, a typed author, a time, a message, the human number, the generation GxHash-128 of its canonical binary encoding

ObjectIds have 64 lowercase hexadecimal digits. Keep the full returned ID when storing or sending it. An upload's 32-digit GX content name is accepted as a stored unit's id in version.commit.

Every object is written once and never changed. Its digest covers the full bytes, including when a text object is stored as a zstd patch against its predecessor. Existing bytes are compared before an address is shared. Different bytes with the same GX digest receive different ObjectIds. Reading an object verifies its digest. The app's versions, branches, and environments can reference the same stored bytes.

A version's generation is 1 + the highest parent's. That is what makes a merge base cost the distance between two heads instead of the whole history.

Binary app bundles from GET /v1/_export use format 4. Each manifest part carries its GX-128 digest in hash. Blob frame names retain the full 64-digit ObjectId. POST /v1/_import verifies the transfer digests and compares existing blob bytes before replacing the database. A content collision returns CONTENT_HASH_COLLISION with status 409 and retryable: false. Both servers must support format 4. substrio pull also prints the downloaded bundle's GX-128 digest.

Rust source cache entries are shared on a node. Source bytes are compared before compiled code is reused. Authentication, signatures, and published tool pins use their cryptographic hashes.

Refs are the one mutable part: a name that points at one version.

Kind Examples What it means
branch main, feature/x a line of work a commit extends
tag v1.4.0 a name fixed on one version, never moved again
environment live, one per sandbox slug the version that environment runs

Refs live in the app's own database, never in RAM and never as loose files. Every move is made durable before it is acknowledged, and every move is a compare-and-swap against the record the caller read, so two agents committing to one branch produce one winner and one CONFLICT: a commit is never lost and never silently overwritten. Every move appends to the ref log (old version, new version, author, time, typed reason), so a deleted branch, a bad merge and a promote are all undoable by moving the name back to an id the log holds.

History is the one place that joins them: a commit writes the objects the change needs, reserves the app's next version number, and moves the branch with one compare-and-swap.

A unit's path inside a version is a byte path with / between its segments (functions/hello.js, site/index.html), and the segments are the tree nodes: a commit that changes one function reads and rewrites only the directories on that path, so every unchanged directory is shared with the parent and costs nothing. A commit is either changes (the units and removals are applied to the parent) or snapshot (the units are the whole version); either way the version that comes out is a complete tree, so reading a version never replays a chain of changes.

The numbers are a view, not a second store

substrio push and version.create return a number, and GET /v1/_versions lists the app's versions and identifies the live version. The numbered view is backed by the version history:

  • A pushed version is a commit on the app's main branch, or on branch as described below. Its tree contains the version unit by unit, so version.log, version.diff, and merges see the versions created by substrio push.
  • Each number assigned by the app is an alias in its refs. <n> resolves to the version object and manifest unit, so version n is read directly. A version.commit commit gets its number in the same way, the first time it is read as a version (see Developing over MCP). The content id is also an alias. Pushing the same content twice returns the existing version with existed: true.
  • live is the environment ref for the app's running version. Each sandbox has an environment ref named with its slug.
  • The live stack is the ref's log. Promotion moves the ref and records the version number and backup. Rollback moves it back.

What a push writes, path by path:

Path Holds
manifest.json the canonical version bytes returned by version.get and version.list
functions/<name>.js the function's source (the same blob the app's content store already names, so a push copies no source)
functions/<name>.json that function's entry point, routes, vars and site vars
site/<path> each site file, using the blob in the content store
rules.json, mcp.json the rules document and the MCP manifest
config/<part>.json one file per declared part (plans, auth, login, queues, permissions, ledger, cron, triggers, collections), only when the version declares it

version.list returns up to limit versions, newest first, below cursor. Use the previous page's next_cursor for the next page. It is null on the last page. An invalid cursor or a limit of 0 returns INVALID_ARGUMENT naming the invalid field.

When a new version is pushed, versions beyond the retention count leave the numbered view, oldest first. Versions on the app or sandbox live stacks are retained. Their objects remain in history until nothing references them and version.gc removes them.

The verbs

Every row is one operation; the CLI word is a thin call to it, and --json prints the server's answer verbatim.

Git habit Operation Route CLI
git branch branch.list GET /v1/_branches substrio branch
git branch NAME branch.create POST /v1/_branches substrio branch create NAME [--from REF]
git branch -d NAME branch.delete DELETE /v1/_branches/{name} substrio branch delete NAME
branch protection branch.protect POST /v1/_branches/{name}/protect substrio branch protect NAME [--off] [--checks A,B]
git tag tag.list GET /v1/_tags substrio tag
git tag NAME tag.create POST /v1/_tags substrio tag NAME [VERSION]
git commit version.commit POST /v1/_commits no CLI word (substrio push commits a whole version through version.create)
git log version.log GET /v1/_commits substrio log [PATH] [--branch NAME]
git show version.show GET /v1/_commits/{version} -
git show --stat version.changes GET /v1/_commits/{version}/changes -
git show REV:path version.unit GET /v1/_commits/{version}/unit -
git ls-tree -r source.tree GET /v1/_commits/{version}/tree substrio source tree VERSION
git grep source.search GET /v1/_commits/{version}/search substrio source search VERSION --pattern X
an editor's save, then git commit source.edit POST /v1/_source/edit substrio source edit
git mv, then git commit source.move POST /v1/_source/move substrio source move
git merge merge.plan POST /v1/_merges substrio merge FROM [--into NAME]
git status (in a merge) merge.get GET /v1/_merges/{branch} substrio merge status
git add (a resolution) merge.resolve POST /v1/_merges/{branch}/resolve substrio merge resolve PATH --file F
git commit (the merge) merge.commit POST /v1/_merges/{branch}/commit substrio merge commit -m MSG
git merge --abort merge.abort DELETE /v1/_merges/{branch} substrio merge abort
git reflog ref.log GET /v1/_refs/{kind}/{name}/log substrio ref log branch main
git reset to a reflog entry ref.restore POST /v1/_refs/{kind}/{name}/restore substrio ref restore branch NAME
git gc --prune version.gc POST /v1/_gc substrio gc [--dry-run]
- version.gc.status GET /v1/_gc substrio gc --status

The release verbs, with their routes and commands:

Operation Route CLI
version.create POST /v1/_versions substrio push [-m MSG] [--branch NAME]
version.check POST /v1/_versions/_check -
version.list GET /v1/_versions substrio versions
version.get GET /v1/_versions/{number} -
version.diff GET /v1/_versions/{from}/diff/{to} substrio diff [A] [B]
version.live.set POST /v1/_live substrio promote [N], substrio rollback
version.restore POST /v1/_restore substrio restore [BACKUP_ID]

Committing

version.commit writes one commit on a branch: the units it changes (bytes as text, as base64, or the id of an object the app already holds), the paths it removes, and what it did.

  • mode: "changes" applies them to the parent version; mode: "snapshot" makes the units the whole content.
  • expected is the version the branch must still point at, so a racing commit is refused rather than overwritten.
  • It answers {version, root, number, generation, branch, blobs_written, trees_written}.

A path is a file or a directory, never both: one commit that writes docs and also writes docs/one.md is refused. A commit that removes docs and writes docs/one.md is the file becoming a directory and goes through; the other way round takes two commits, the first removing what the directory holds.

substrio push --branch NAME commits the whole built version on NAME instead of main. The branch is a request option of version.create, not part of the app's manifest: the version's content, and so its hash, is the same whichever branch it is committed on. A branch named by a push that has no version yet is created at that version.

Reading the history

  • version.log answers the commits of a branch, newest first, following first parents: each one's id, number, generation, parents, author, time and message. branch names the branch (default main), limit caps the answer, and path narrows it to the commits that changed that one unit: the history of one function, route or file. substrio log functions/hello.js is that. The answer's next_cursor (null on the last page) is where the next page starts: pass it as cursor. A page of limit costs the commits from the cursor on, never a walk from the head.
  • version.show answers one commit's own record. The {version} in the route is an id, a number, or a branch name whose head it is. units: true answers every path it holds as well.
  • version.changes answers what one commit changed: every path that differs from the version it is compared against, with the object ids on both sides. against names another version; the default is the commit's first parent, so the answer is the commit's own change.
  • version.unit answers one unit's bytes out of a version: text when the bytes are text, base64 when they are not, with the size.

A diff reads only the directories that differ, so it stays fast on a large project.

Branches and protection

branch.create names a branch and where it starts (from: a branch name or a version id; default the head of main). branch.list answers every branch with the version it points at, when it last moved, whether it is protected and the checks a move onto it needs green.

branch.delete frees the name. Its versions are kept (another name or a merge may still reach them) and its log survives, so ref.restore can put the name back. main is never deleted, and a freed name is available again.

branch.protect protects a branch, or unprotects it with protected: false. While it is protected, every commit or merge onto it must say its required_checks are green. An empty required_checks protects it with no named check, which still needs a green verdict. On the CLI that is substrio merge commit --checks-green.

Tags and the ref log

tag.create marks a version with a name that never moves again: a version by id or number, or a branch whose head it marks (default main). A taken name answers 409.

ref.log answers every move of one name (the route's {kind} is branch, tag or environment), newest first: the version it moved from, the version it moved to, who moved it, when, and why (create, commit, merge, promote, rollback, restore, delete, protect, import). The log of a deleted name is kept, which is what ref.restore reads.

ref.restore puts a name back to a version its own log holds, named directly or by the log entry's sequence. A name that was deleted is made again; one that still lives is moved, which is itself one more log entry. A tag is never moved, only made again at the version it marked.

A ref-log entry keeps the versions it names for a retention window (30 days by default). That window is also what version.gc treats as reachable.

Merging

A merge is three-way: the two heads and the version both descend from (the merge base). For every unit, what each side did to the base settles it, so a change the other side never touched is kept without a question, and only a unit both sides changed is work.

The merge base comes out of a generation walk (a heap over generations, popping the deepest version first), so it costs the number of versions between the heads and the base, never the whole history.

A unit both sides changed is merged by lines. Each side's diff against the base is the regions of the base it replaced; regions that do not touch are applied one after the other, and a region both sides replaced is settled by three compares (ours equals base: take theirs; theirs equals base: take ours; the same change: take it once) or reported as a conflict with both texts between <<<<<<< ours, ||||||| base, ======= and >>>>>>> theirs markers.

Nothing a merge decides is published until the merge commit:

  1. merge.plan works the merge out and keeps it as into's merge draft: durable, in the app's database, one per branch. It publishes nothing and answers {into, from, base, ours, theirs, up_to_date, fast_forward, conflicts, clean, units, count}.
  2. merge.get answers that draft as the plan and the resolutions left it.
  3. merge.resolve settles one conflicted unit with the bytes it should hold, as text or base64, durably, and answers the whole draft again so the next conflict is in hand.
  4. merge.commit publishes it: one commit of the merged units onto the branch with the other side recorded as the second parent. It answers 409 while a unit is still conflicted or when the branch moved under the draft. A fast forward moves the branch instead of committing, and a branch already holding the other side's work is answered as up to date.
  5. merge.abort throws the draft away. The branch never moved, so nothing of the app changes; the blobs the merge wrote are unreferenced and the next version.gc sweeps them.

Because the draft is durable, a conflict settled over days is never broken by a collection: version.gc treats every merge in hand as a root.

Shipping: promote, sandboxes, rollback

version.live.set is the one apply. It makes a version live, or rolls back with version: "previous":

  • Before anything changes it backs the app's data up (unless backup: false) and, unless check: false or this is a rollback, runs function.check over the version's functions, refusing with 400 and the diagnostics when one has an error.
  • The backup is a replica of the app's data, which the app's change recorder makes possible, and every app the server holds has one from the moment it opens, the identity app included. An app without one cannot be backed up. The promote then answers 409 BACKUP_NO_RECORDER and changes nothing. A backup that fails for any other reason still answers 500 and changes nothing as well. backup: false skips the backup, which is safe only for a version that touches no data.
  • A version with a blob not uploaded cannot go live: upload what version.get lists in missing with site.blob.upload.
  • sandbox acts on that sandbox instead of the app: that is the environment ref named with the sandbox's slug. sandbox.create, sandbox.list, sandbox.reset and sandbox.delete manage the sandboxes themselves.
  • direct settles what happens when a function is deployed directly (outside a version) after the live version is recorded: refuse (the default) answers 409 with direct_changes, replace applies the version anyway, keep applies it except those functions.
  • from instead of version promotes what an environment runs: a sandbox name ("from": "dev"), or a test host bound to one of the app's environments ("from": "my-app-v2.substrio.com", test-hosts.md). The version that environment runs now goes live through this same apply. An unknown sandbox or host, or one running no version, is 404 ENVIRONMENT_NOT_FOUND with field: "from". version and from together are refused.
  • plan: true answers the plan of the change and applies nothing (see below).
  • It answers live, previous, rollback_to, sandbox and backup_id.

version.restore puts the app's data back from the backup a promote took (the latest when none is named) and makes live again the version that was live then.

The promote itself takes nothing down: every request runs wholly on the old or wholly on the new version.

Developing over MCP

An app's history tree is its project. An agent with one account key lists it, reads it, edits it, searches it and commits to it with plain MCP calls, and ships any commit the way a pushed version ships. No call takes a hash: the server names every object it stores. The order of the calls (commit, run it in a sandbox, call it, make it live, roll back) is agents.md, "The normal flow", and every tool result names the call that comes next and the field of its answer to pass on (mcp.md, "What comes next").

The loop:

  1. Tree. source.tree {version, prefix?, glob?, sizes?} lists what a version holds, ordered by path, each entry {path, kind} (file or dir). prefix walks only that directory (a prefix that is a file answers that file), glob keeps the files whose path matches (* and ? also match /, so *.css is every CSS file), sizes: true adds each file's size.
  2. Read. version.unit {version, path} answers one unit: text when it is text, base64 when it is not, with its size. start_line and end_line read lines [start_line, end_line) (lines from 1, the end left out, as function.source.read counts them) and answer lines, the unit's whole line count; offset and length read a byte range; max_bytes caps the answer and adds truncated: true when it cut. A text range never ends inside a character.
  3. Edit. source.edit {branch, path, edits: [{old, new, replace_all?}], expected?, message?} replaces exact text and commits it. Each old must occur exactly once (or every occurrence goes with replace_all: true); the edits apply in order, each on the last one's result, and they land as one commit or none does. expected is the version the branch must still be at. A binary unit is written whole with version.commit.
  4. Search. source.search {version, pattern, regex?, glob?, context?, max_matches?, max_bytes?} finds the lines of the version's text units that hold pattern (exact bytes, or with regex: true a regular expression matched line by line in linear time). Each match is {path, line, text}, with before and after when context (at most 20) asks for lines around it. max_matches (default 200) and max_bytes (default 65536) bound the answer; truncated says one of them cut it.
  5. Move. source.move {branch, from, to, expected?, message?} moves a unit as one commit: the old path removed and the same object written at the new one, no byte copied. to must be free.
  6. Commit. Writing a new unit and deleting one are version.commit: units (each {path, text}, {path, base64}, or {path, id} for an object the app already holds, such as an upload) and removals. Every commit answers its number.
  7. Sandbox and promote. version.live.set {version: <number>, sandbox: "dev"} runs the commit in a sandbox; version.live.set {version: <number>} makes it live. Any commit of any branch is runnable and promotable.

Every version argument takes the same five forms through one resolver: a version id, a number, a branch name (its head), live (the version the app runs) or a sandbox's name (the version that sandbox runs).

A commit as a version

A commit's tree is read as a version by these paths:

Path Becomes
functions/<name>.json declares function <name>: its main, routes, vars, site_vars and tools (which routes are MCP tools, mcp.md "Out of the box")
functions/<name>.js that function's source
site/<path> site file <path>
rules.json, mcp.json the rules document and the MCP manifest
config/<part>.json the declared part: plans, auth, login, queues, permissions, ledger, cron, triggers, collections

A function is declared by its .json, directly under functions/, and nothing else: every other .js unit (one with no .json beside it, functions/lib/*.js, shared/*.js) is a module, never a function. A functions/<name>.json with no functions/<name>.js is refused naming the .json. Any other path is the project's own (notes, tests, a README) and is kept in the history but is no part of what runs. A commit gets its number when it is made: the number is an alias to the commit with no manifest yet. The first version.get, promote or sandbox run of that number reads the tree once, checks it exactly as a push is checked, writes the manifest and points the alias at it; every read after that is as direct as a pushed version's. A tree that cannot be a version is refused naming the path at fault, and nothing is written. version.list answers every number, a commit's too, the moment it is made: each row carries version (the commit id) and manifest, read with the version's id, functions and site_files, or pending (number, message and time from the commit itself) until its first read as a version. One read a row either way, never a tree walk.

Large files

A file too large for one call goes up with site.blob.upload and no hash. Over MCP a file larger than mcp_blob_max_bytes goes in parts: the first part (offset: 0, total) answers an upload id, every later part carries it, and the last answers hash and size (see operations/static-sites.md). That hash is the object's id: version.commit takes it as the unit's id, and no byte is sent again.

After a sandbox is created, the live app is promote-only (LIVE_BY_PROMOTE). Direct function.deploy calls to the live app are refused. Change a function through source.edit or version.commit, then promote the version.

Worked example: an agent ships a site and a function

Every call below is a plain MCP tools/call, sent with the account key; app names the app. No argument is a hash.

{"name": "substrio_version_commit", "arguments": {"app": "shop", "branch": "main", "message": "v1",
  "units": [
    {"path": "functions/hello.js", "text": "export default () => new Response('hello v1');\n"},
    {"path": "functions/hello.json", "text": "{\"routes\":[\"GET /hello\"]}"},
    {"path": "site/index.html", "text": "<!doctype html><h1>Shop</h1>\n"},
    {"path": "site/style.css", "text": "body { color: #123456; }\n"},
    {"path": "site/img/logo.png", "base64": "iVBORw0KGgoAAAANSUhEUg=="}]}}

answers {"version": "...", "number": 2, "branch": "main", ...}. Then:

{"name": "substrio_source_tree", "arguments": {"app": "shop", "version": "main", "prefix": "site", "sizes": true}}
{"name": "substrio_version_unit", "arguments": {"app": "shop", "version": "main", "path": "functions/hello.js", "start_line": 1, "end_line": 2}}
{"name": "substrio_source_search", "arguments": {"app": "shop", "version": "main", "pattern": "hello", "glob": "functions/*.js"}}
{"name": "substrio_source_edit", "arguments": {"app": "shop", "branch": "main", "path": "functions/hello.js",
  "edits": [{"old": "hello v1", "new": "hello v2"}]}}
{"name": "substrio_sandbox_create", "arguments": {"app": "shop", "name": "dev"}}
{"name": "substrio_live_set", "arguments": {"app": "shop", "version": 3, "sandbox": "dev"}}
{"name": "substrio_route_call", "arguments": {"app": "shop", "method": "GET", "path": "/hello", "sandbox": "dev"}}
{"name": "substrio_live_set", "arguments": {"app": "shop", "version": 3}}
{"name": "substrio_version_unit", "arguments": {"app": "shop", "version": "live", "path": "functions/hello.js"}}

The edit answers the new commit's number (3 here) and replaced: 1; the sandbox runs it, the promote makes it live, and the last read answers what live runs.

Refusals

Code Status When Field
EDIT_NO_MATCH 409 an edit's old does not occur in the unit (after the edits before it); edit names it, closest_line the line most like old's first line the unit's path
EDIT_AMBIGUOUS 400 an edit's old occurs more than once and replace_all is not set; count is how many times, lines the lines (the first 16) the unit's path
INVALID_ARGUMENT 400 a commit that cannot be a version: functions/<name>.json with no functions/<name>.js, a meta or config part that is not the JSON it must be the path at fault
INVALID_ARGUMENT 400 source.edit on a unit that is not text; an end_line not after start_line; a regex that does not compile the path, or the argument
NOT_FOUND 404 a path, prefix or from the version does not hold; a version that names nothing; live or a sandbox that runs no version the path
CONFLICT 409 source.move to a path a unit holds already (field to); expected that is not the branch's head to
STORAGE_QUOTA_EXCEEDED 507 the app's storage limit does not hold the bytes a version's first read places in its content store the path

What a live change does to stored data

A version carries its collections' declared shapes, so making it live can change a declared field's type. Call version.live.set with plan: true first to see what the change would do to each collection, with nothing applied: no check, no backup, no row written, the live version still serving. Once the version is live, documents already stored convert to the new type in the background; a read answers the declared type right away, even for a document the background conversion has not reached yet.

Collecting what no name reaches

Objects are immutable. A commit, reset, deleted branch, or aborted merge can leave objects that no name reaches. Those objects still use disk space. version.gc removes them in two phases:

  • mark: every version a name can still reach (each ref of each kind, a deleted ref's own, and both ends of every still-retained ref-log entry), plus the versions and the bytes of every merge in hand. From those roots: every ancestor version, its root tree, every tree below it, every blob they name, and the predecessor of any object the blob store happens to hold as a patch, without which the kept object stops being readable.
  • sweep: one ordered walk of the app's own version objects (never its uploaded files), erasing an object the mark phase did not reach. A file written after the collection began is kept whatever the mark says, so a commit that lands mid-sweep is never collected, and a name that is not a content address is never touched.

A collection is resumable: a long sweep checkpoints partway through, writes where it stopped, and the next call carries on from there with the start time of the first run. dry_run marks and walks and erases nothing; it writes no record and resumes nothing. version.gc.status answers where the collection in hand, or the last one that finished, got to, with the same counts: {started_at, finished_at, roots, marked, missing, read, collected, bytes, kept_new, foreign, dry_run, complete, resume_after}.

Sandbox storage

sandbox.create (POST /v1/_sandboxes) and sandbox.reset (POST /v1/_sandboxes/{name}/reset) clone the newest completed backup when a reflink volume is configured. The source must already be on that volume. The live database remains on ext4. It shares no database blocks with the backup or sandbox. Live reads and writes perform no additional storage checks.

The optional fresh boolean defaults to false. Creation accepts fresh: true to take a new backup before cloning. This is an explicit database copy onto the volume. Creation without fresh, and reset, copy no database bytes. Reset always starts from a frozen backup, including when the sandbox has been changed. The clone is scrubbed with the existing sandbox exclusions before it is opened for requests.

Creation reserves the sandbox name with an inactive tenant record. Its slug and domains become available only after the database, files, configuration, and live version are ready. Concurrent requests receive the existing unknown-app refusal before publication and the completed sandbox afterward. They cannot open an empty database while the copy is being installed. The sandbox remains inactive until publication, with its name and volume capacity reserved during preparation. If an incomplete sandbox remains, delete it before retrying creation.

The backup's retained backups/<id>.blobs/ set supplies the sandbox's files, tile bytes, and version objects. These immutable files are linked on ext4. Later changes to the app's files do not change the bytes available from that backup. The same blob-store link operation is used for backups and sandboxes, including uploaded files and configured version-object folders. The backup record is read again after cloning and linking. If it is no longer completed, that preparation is discarded and the next newest backup is tried. Deleting a backup does not remove the database blocks or file links retained by the sandbox.

During reset, the current database supplies the version refs. They are synced to refs.carry beside the replacement database before its storage assignment is published. The current storage assignment remains in use until publication, with capacity reserved for both generations. Retrying reset carries the current refs again. An old copy in the replacement cannot rewind the version numbers.

Without a reflink volume, sandbox preparation uses the existing replica copy. Before it starts, the source's used bytes are compared with the server's copy limit. The default is 4294967296 bytes (4 GiB). A larger estimate is refused before the destination is copied. fresh does not bypass this limit on a host without a volume. The source can grow during a replica copy, so this is a preflight limit rather than a byte counter inside the copy.

Successful create and reset replies include storage_method and bytes_copied. A filesystem clone reports "reflink" and zero copied database bytes. A replica copy reports "copy" and the bytes allocated in the destination when the copy completes. This is the destination's allocated block count multiplied by 512. It excludes holes and does not measure write traffic or later scrub and allocation work. These fields describe the sandbox preparation, not a backup requested with fresh.

Once the volume is configured, you can take a fresh backup and create a sandbox through the authenticated tenant API:

POST /v1/_sandboxes
Content-Type: application/json

{"name":"dev","fresh":true}

The successful reply includes "storage_method":"reflink" and "bytes_copied":0. A later POST /v1/_sandboxes/dev/reset clones the newest completed backup again. A sandbox's private disk consists of the blocks it writes plus the headroom allocated by the core. The existing allocation and prefault window remain enabled. A clone's logical file size is not its private disk consumption.

The sandbox's storage location and size ceiling persist across restarts. Reaching that ceiling returns DISK_FULL. A missing volume, storage assignment, or database file causes an error. It never causes an empty database to be created at the old location. Reset reserves space for both database files until the replacement is published and the previous file is closed.

Generic database imports and backup restores into an assigned sandbox require a separately admitted generation. An older caller that does not supply one is refused with SANDBOX_GENERATION_REQUIRED. Use sandbox.reset to return to the latest frozen base. Failed or interrupted preparations remain reserved until their files and assignments have been reconciled. They are never released automatically because an operation returned an ambiguous storage error.

Sandbox refusal code Meaning
SANDBOX_REFLINK_REQUIRED The source's used bytes exceed the configured copy limit. No database copy starts.
SANDBOX_BASE_MISSING No completed backup is available. Creation with fresh: true takes one.
SANDBOX_BASE_NOT_ON_VOLUME The newest base is outside the configured volume or the clone crosses filesystems.
SANDBOX_BASE_BLOBS_MISSING The completed backup has no retained blob set.
SANDBOX_REFLINK_UNSUPPORTED The filesystem cannot perform the required clone. No copy fallback runs.
SANDBOX_CLONE_FAILED The required database clone failed.
SANDBOX_COPY_FAILED The legacy replica copy failed.
SANDBOX_SCRUB_FAILED The prepared database could not be scrubbed.
SANDBOX_OBJECTS_FAILED The retained file bytes could not be linked.
SANDBOX_REPLACE_FAILED The replacement could not finish. A published assignment may already exist.
SANDBOX_CLEANUP_FAILED A discarded preparation could not be removed. Its reservation remains counted.
SANDBOX_FRESH_BACKUP_FAILED The explicitly requested fresh base could not be completed.
SANDBOX_PUBLISH_FAILED The prepared sandbox's tenant record could not be activated.
SANDBOX_CEILING_INVALID An existing file's used end or format cannot be opened within its admitted ceiling.
SANDBOX_DATABASE_MISSING The persisted assignment names a missing database.
SANDBOX_GENERATION_REQUIRED A database replacement did not supply a new admitted generation.

The admission refusals below also apply. A SANDBOX_VOLUME_FULL reply includes required_bytes and available_bytes from the admission check.

See also

  • agents: the agent guide's push, sandbox, promote loop.
  • test hosts and root domains: a sandbox on a host the agent chooses, and promote from it.
  • project files: what substrio.toml and collections/<name>.json declare, and so what a version carries.