Versioning

An app has one history: push a commit, move a branch or a tag, merge, diff and garbage collect it with git-like verbs.

Docs menu

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 main branch (or on branch, see below) whose tree holds the version unit by unit, so version.log, version.diff and a merge all see exactly the versions substrio push pushed.
  • 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. A version.commit commit 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).
  • live is 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.
  • 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.
  • 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 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.toml and collections/<name>.json declare, and so what a version carries.