Everything that ships goes through that history. substrio push writes a commit; substrio promote moves a name; a branch, a tag, a merge, a log and a garbage collection are the same objects seen from a different name. There is no second store for "the version numbers" and no separate store for "the git-like part": the numbered versions are a view over the objects and the refs below.
A developer who knows git finds the same verbs: branch, log, tag, merge, diff, gc. What they operate on is the app, not a working copy.
The model: objects, refs, history
Three files own the three layers, and nothing else owns any part of them.
Objects are immutable and content-addressed. Exactly three kinds:
| Kind | What it is | Its id |
|---|---|---|
| blob | one unit's bytes (a function's source, a site file, a rules document) | SHA-256 of the bytes |
| tree | a sorted list of (name, kind, 32-byte hash) |
SHA-256 of its canonical binary encoding |
| version | a root tree, 0-2 parents, a typed author, a time, a message, the human number, the generation | SHA-256 of its canonical binary encoding |
Every object is written once and never changed. Reading one checks its bytes against the id that named it, so a corrupted object is an error and is never used. The bytes live in the account's one blob store, so an unchanged unit is stored once across every version, branch and environment of that account (and never shared across accounts). A text object may be stored as a zstd patch against its predecessor: that is the blob store's own storage form; an object's id is always the hash of its full 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.
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 still answer a number, and
GET /v1/_versions still lists the app's versions with what is live. Underneath:
- A pushed version is a commit on the app's
mainbranch (or onbranch, see below) whose tree holds the version unit by unit, soversion.log,version.diffand a merge all see exactly the versionssubstrio pushpushed. - Every number the app ever handed out is an alias in its refs:
<n>resolves to the version object and its manifest unit, so version n is read directly. Aversion.commitcommit gets its number the same way, read as a version the first time it is asked for (see Developing over MCP). The content id is an alias too, which is why pushing the same content twice gives back the version that already holds it (existed: true). liveis an environment ref: the app's running version. Each sandbox has the environment ref named with its own slug.- The live stack is that ref's log. A promote is one move that records the number and the backup it took, and a rollback is a move back.
What a push writes, path by path:
| Path | Holds |
|---|---|
manifest.json |
the version's own canonical bytes, which version.get and version.list answer |
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, again the content store's own blob |
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 pages too: limit versions, newest first, below cursor (the
number a page answered as next_cursor, null on the last page). A bad cursor or a limit of 0 is
INVALID_ARGUMENT naming it.
Versions beyond the kept count leave the numbered view when a new one is pushed,
oldest first, except those on the app's or a sandbox's live stack. Their objects
stay in the history until nothing names them and version.gc sweeps 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. - 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 its first promote an app is promote-only (LIVE_BY_PROMOTE): function.deploy
is refused, and a function changes through source.edit or version.commit and a
promote.
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
An object is written once and never changed, so nothing deletes one on its own.
What a commit, a reset, a deleted branch or an aborted merge leaves behind is an
object no name reaches: it costs disk and nothing else. version.gc is the one
owner of removing those, 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}.
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.