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
mainbranch, or onbranchas described below. Its tree contains the version unit by unit, soversion.log,version.diff, and merges see the versions created bysubstrio 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. Aversion.commitcommit 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 withexisted: true. liveis 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.expectedis 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.loganswers the commits of a branch, newest first, following first parents: each one's id, number, generation, parents, author, time and message.branchnames the branch (defaultmain),limitcaps the answer, andpathnarrows it to the commits that changed that one unit: the history of one function, route or file.substrio log functions/hello.jsis that. The answer'snext_cursor(null on the last page) is where the next page starts: pass it ascursor. A page oflimitcosts the commits from the cursor on, never a walk from the head.version.showanswers one commit's own record. The{version}in the route is an id, a number, or a branch name whose head it is.units: trueanswers every path it holds as well.version.changesanswers what one commit changed: every path that differs from the version it is compared against, with the object ids on both sides.againstnames another version; the default is the commit's first parent, so the answer is the commit's own change.version.unitanswers one unit's bytes out of a version:textwhen the bytes are text,base64when 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:
merge.planworks the merge out and keeps it asinto'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}.merge.getanswers that draft as the plan and the resolutions left it.merge.resolvesettles 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.merge.commitpublishes it: one commit of the merged units onto the branch with the other side recorded as the second parent. It answers409while 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.merge.abortthrows the draft away. The branch never moved, so nothing of the app changes; the blobs the merge wrote are unreferenced and the nextversion.gcsweeps 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, unlesscheck: falseor this is a rollback, runsfunction.checkover the version's functions, refusing with400and 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_RECORDERand changes nothing. A backup that fails for any other reason still answers500and changes nothing as well.backup: falseskips 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.getlists inmissingwithsite.blob.upload. sandboxacts on that sandbox instead of the app: that is the environment ref named with the sandbox's slug.sandbox.create,sandbox.list,sandbox.resetandsandbox.deletemanage the sandboxes themselves.directsettles what happens when a function is deployed directly (outside a version) after the live version is recorded:refuse(the default) answers409withdirect_changes,replaceapplies the version anyway,keepapplies it except those functions.frominstead ofversionpromotes 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, is404 ENVIRONMENT_NOT_FOUNDwithfield: "from".versionandfromtogether are refused.plan: trueanswers the plan of the change and applies nothing (see below).- It answers
live,previous,rollback_to,sandboxandbackup_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:
- Tree.
source.tree {version, prefix?, glob?, sizes?}lists what a version holds, ordered by path, each entry{path, kind}(fileordir).prefixwalks only that directory (a prefix that is a file answers that file),globkeeps the files whose path matches (*and?also match/, so*.cssis every CSS file),sizes: trueadds each file'ssize. - Read.
version.unit {version, path}answers one unit:textwhen it is text,base64when it is not, with itssize.start_lineandend_lineread lines[start_line, end_line)(lines from 1, the end left out, asfunction.source.readcounts them) and answerlines, the unit's whole line count;offsetandlengthread a byte range;max_bytescaps the answer and addstruncated: truewhen it cut. A text range never ends inside a character. - Edit.
source.edit {branch, path, edits: [{old, new, replace_all?}], expected?, message?}replaces exact text and commits it. Eacholdmust occur exactly once (or every occurrence goes withreplace_all: true); the edits apply in order, each on the last one's result, and they land as one commit or none does.expectedis the version the branch must still be at. A binary unit is written whole withversion.commit. - Search.
source.search {version, pattern, regex?, glob?, context?, max_matches?, max_bytes?}finds the lines of the version's text units that holdpattern(exact bytes, or withregex: truea regular expression matched line by line in linear time). Each match is{path, line, text}, withbeforeandafterwhencontext(at most 20) asks for lines around it.max_matches(default 200) andmax_bytes(default 65536) bound the answer;truncatedsays one of them cut it. - 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.tomust be free. - 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) andremovals. Every commit answers itsnumber. - 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.tomlandcollections/<name>.jsondeclare, and so what a version carries.