Release, update and compatibility
Status: design for the distribution stages RL0–RL5 in the
roadmap
(D-043, amended by D-132 and
D-202).
Implemented: version.txt (0.0.0) and the pull request and main pipeline
under CI/CD. Source candidate until its first canary publishes:
publishing canary from
.github/workflows/publish.yml, and
installing and switching builds on one machine
and updating it.
Everything else is planned.
Maia runs on other people's machines next to their work, so a release that breaks an existing install is worse than a late one. Maia stays on the canary channel for good (D-202): there is no stable or next release, and every build is a canary.
Principles
- Build once, ship the same bytes. CI builds each artifact once, tests that exact tarball, and publishes it unchanged; the release records its digest. A published version is never replaced; a fix is a new version.
- Test what users install. Every candidate is installed from its tarball into a clean prefix on macOS and Linux, and upgraded from the newest, the oldest supported and one middle published version with seeded state. The running Node, not the installed files, must report the new version.
- No waivers. A missing, skipped or failed required gate means the build does not ship. Nobody can force a release past a gate.
- One channel, nothing promoted. Maia ships canary builds only. No build is promoted to another channel, and no version without a canary suffix is ever published. A canary is not a license to break an install: configuration stays backward-compatible, migrations are forward-only and safe (principle 6), and a breaking change ships with a migration and a changelog note.
- Old and new talk or refuse clearly. Every connection starts with a version handshake. A mismatch fails before any operation, names which side is behind and prints the exact command that fixes it.
- State only moves forward, safely. Every migration is forward-only, takes a verified backup first and writes a receipt. An older binary refuses newer state; a rollback restores the backup.
- One owner per daemon. One installer owns one service label per profile. An update that changes code always restarts that service and then checks the version that actually runs.
- Renames keep working. Renames and removals follow the compatibility policy. New names are reserved before use.
Versions and channels
All public packages (@maiaos/protocol, sdk, agent, node, client-core,
cli) and the desktop app share one version.
version.txtholds the base version of the canary line (0.0.0today). Maia never releases a plain version: every build is a canary (D-202).- The upcoming version is computed from the Conventional Commits since the last
base release tag, of which none exists:
featraises the minor;fixor no relevant commit raises the patch;!orBREAKING CHANGEraises the minor while the major is 0 (the major after 1.0). With0.0.0andfeatcommits the line is 0.1.0. - Canaries are pre-releases of that upcoming version, so they sort after every earlier line and before the plain version they are numbered for. CI derives them (RL1) and never commits them. Commit identity lives in build metadata, not in the version.
- The channel is always derived from the version suffix:
| Channel | Version | npm dist-tag | Built from | Who uses it |
|---|---|---|---|---|
| canary | 0.1.0-canary.N | canary, latest | every green push to main, and a dispatch from main on demand | everyone |
- Canary is the only channel, now and after the public launch. There is no stable, next or nightly build, no promotion between channels and no soak stage; the release scripts and the workflow refuse any other version (D-202). Nightly builds published before D-185 stay on npm and still install.
latestfollowscanary: it names the newest canary published. The last job of the publish workflow moves it once the canary has installed from npm and has its tag and its GitHub release, whether a green push or a dispatch frommainpublished it.latestnever moves back to an older canary.- A bad release is never unpublished: deprecate it, move the dist-tags back to the previous good version, ship a fix as a new version.
- The release workflow writes the changelog from the same commits, tags the
release (for example
v0.1.0-canary.7), creates the GitHub release with artifacts and digests, and links the evidence of every gate.
CI/CD
Pull requests and pushes to main are implemented; the jobs and scope
rules are in development. The target is under 10 minutes
for a typical change, with the pnpm store and Turborepo cache restored from the
GitHub Actions cache (no hosted remote cache). Actions are pinned to commit
SHAs; new dependency versions wait 7 days (minimumReleaseAge), except the
packages excluded in pnpm-workspace.yaml, and pnpm re-checks every lockfile
entry on install.
Release pipeline (RL0–RL1; one workflow; canary is a source candidate, see publishing canary; the rest is planned):
- Resolve the exact commit and require a green
mainCI run on it. - Build once: the
linux-x64process host on Linux, then the packages and thedarwin-arm64process host on macOS, which embeds both. Pack every package and record its digest. - Test the tarballs, not the workspace: a clean install and an upgrade-from-published-versions run on macOS and Linux (install, start the Node as a service, handshake, run a worker on a fixture, drain, update, check the running version, roll back).
- Publish the exact tarballs to the
canarydist-tag with trusted publishing (OIDC, no stored tokens). - Verify the registry serves the same integrity, then create the tag, the
GitHub release and the changelog, and move
latestto the canary.
One Node package. The Node embeds the digest of each native helper at build
time, so @maiaos/node is one tarball that carries the darwin-arm64 and
linux-x64 helpers built from the same commit. The Node starts with the helper
for its platform; a platform with none fails closed
(D-134).
Publishing canary
Source candidate (D-132, D-185,
D-202).
.github/workflows/publish.yml plans the version on a green commit, compiles
the Linux process host on Ubuntu, builds the tarballs once on macOS, installs
and starts them on macOS and on Linux, publishes those bytes through OIDC,
installs them back from npm and tags a GitHub pre-release. Every green CI run
of a push to main publishes a canary: the newest green push wins, and a
push superseded while another publish runs is skipped (the next canary carries
its changes). Only a real run of main publishes, and only its tip: the plan
refuses an older commit, so a rerun of an older run publishes nothing, and a
dispatch from another branch only builds and tests. The release scripts and
the workflow refuse stable, next and nightly versions (D-202). The latest job
runs last, for every canary that publishes, and moves latest of every public
package to it, never back to an older canary; a dist-tag change through OIDC
needs it allowed on each package's npm trusted publisher.
Both installs are lockfile-free, so a package's loose dependency ranges float
to whatever npm serves that day. @maiaos/agent therefore pins every Pi
package that pi-coding-agent brings to the exact version the lockfile holds
(the workspace catalog). That freezes the Pi packages, not what their own
dependencies ask for. So each install runs installed-lifecycle.mjs against
the installed tree: the agent imports and lists the model providers; every Pi
package it reaches is one copy at the agent's pin; Pi's sign-in and Bedrock
entry points import, with every module their import graphs reach, as
pi-coding-agent resolves them; and the installed managed worker runs through
initialize and shutdown. The install of the built tarballs rejects a bad
build before publishing; the install from npm only detects one after it.
Upgrading Pi means rechecking that those entry points still import every
sign-in flow. Replaying the Linux install from the frozen lockfile is not yet
part of the check.
gh workflow run publish.yml --ref main # publish main's tip when no push did
gh workflow run publish.yml -f dry-run=true --ref feat/my-change # build and test only; a branch never publishes
pnpm add -g @maiaos/cli@canary @maiaos/node@canary # or an exact version
- A bad release is never unpublished: deprecate the bad version and ship the fix as the next canary.
- Only a real run of
mainpublishes, and only its tip. A dispatch frommainjoins the same queue as the pushes, so one that fails its green check (a tree CI has not passed) can replace a pending push publish; the next green push publishes its changes. A dispatch from any other branch, and a dry run, build and test and publish nothing, socanaryandlatestalways name source thatmainholds. The plan also refuses a commit that is no longer the tip ofmain, or that the newest canary tag already carries, and says so: rerunning an older run can never give older source a newer canary number and takecanaryorlatestback. - A failed run: use Re-run failed jobs, which keeps the plan and its
version; it skips bytes npm already holds and fails on different ones. A
package whose
canarytag names a newer canary is never published under the older version, so the failed jobs of an older run stop there instead of moving the tag back. Do not re-run the whole workflow of an older run (its plan refuses source that is no longer the tip ofmain); let the next green push, or a dispatch frommain, publish the tip instead. A GitHub release that already exists keeps its files the same way: the job compares the sha256 of each asset with the build, fails on a difference before it uploads anything, and attaches only the assets that are missing. It never replaces a published asset, because an installed version must stay the bytes people verified.
Install and switch builds on one machine
Source candidate (D-137); darwin-arm64 and linux-x64
(prerelease only). Offline
commands of the Node executable, next to state check. They need no running
Node and use the Maia home (MAIA_HOME, else ~/.maia).
maia-node release install canary # or an exact 0.1.0-canary.* version
maia-node release use canary --profile default # preview: names the plan and a digest
maia-node release use canary --profile default --yes # apply, or --confirm <digest> of the preview
maia-node release status # installed, selected and previous versions
~/.maia/
versions/<v>/ one install per version; complete once .maia-installed.json exists
versions/.staging-*/ one install in progress; the journal inside names it
current selected version, pinned Node.js, the profile, previous version (one file)
bin/maia, bin/maia-node launchers: check the pointer, run the selected version
updates/<id>.json one update: its preview, then its receipt (private)
locks/install.lock one install or switch at a time
- Verified, frozen. The build job's lifecycle check writes one install
project next to the tarballs (
maia-install.json: the project'spackage.json, workspace file and lockfile, and the pnpm version that ran), andSHA256SUMScovers it. The install fetches the files, checks every digest, writes the project as it is, and runs that pnpm version (it refuses any other) with a frozen lockfile, no install scripts and no pnpm hook. pnpm gets a home, cache and settings inside the staging directory and npm's registry, so a registry override, a global pnpm hook or a token in the user's own pnpm configuration never reaches the install. A hard-linked process host (anymaia-process-host-*file) fails the install. - Download. Release files come from the asset endpoint of the Maia repository:
the address the release lists must be that endpoint and a number, with no
query, fragment or further path. GitHub answers with one redirect. The
command follows it only to HTTPS on
release-assets.githubusercontent.com,objects.githubusercontent.comorgithub-releases.githubusercontent.com, with no credentials and no port but the default, and sends it noGH_TOKEN. An explicit:443is normalized away, so the request goes to the address without it. Any other address, a second redirect, or a redirect of the release lookup stops the command with nothing downloaded. - Switch rule. The target's own
state checkmust sayabsentorreadable, and must declare the start guard (thestartGuardfield).usenever migrates, backs up or restores data.bin/must be a private directory of the user and no link, as the rest of the home is. - One profile. A home serves one profile for now. The first
usebinds it andcurrentrecords it. Ausefor another profile is refused, in a preview and when applying, andstatusshows the bound profile. Rebinding a home to another profile is a later slice. - Locks and commit.
usetakes the install lock, then the profile's instance lock. A running Node stops it (node_running, exit 75, nothing changed). Renamingcurrentcommits; the plan is rebuilt under both locks and a confirmation bound to another plan is refused. A switch first ends the updates that had not finished. - Start guard. A Node that runs from a
versions/<v>tree of a home reads that home'scurrentagain once it holds the profile lock, and stops unless it is the selected version and the profile is the bound one. The Node finds its home from the resolved location of its own bundle (links and relative paths followed), not from the environment, so a start that skips the launcher, goes through a link or clears a variable meets the same check. When the path holds more than oneversions/<version>directory, the one with the installer's record is the home, and the innermost if several have one. A nearer one whose home has acurrententry but no record is a damaged home (a link counts, even one that leads nowhere), and the start refuses rather than trust an outer record. A Node outside such a tree (a checkout, a global install) is not selected by anything and starts as before. - Durable. The install flushes (fsync) every file and folder of the
finished tree and its record, then renames the tree into place and flushes
versions/. Each folder the command creates gets its parent flushed too. The command walks the tree folder by folder, not with one recursive listing that skips a folder it cannot stat. A link is neither entered nor flushed (its folder's flush covers it, and following it would leave the tree), and an entry that cannot be listed, inspected, opened or flushed stops the install before the rename, so nothing is published. Tests check this order with a recording file system and with injected failures; Node 24.22.1 asks macOS forF_FULLFSYNC. No physical power-loss test has qualified it. - Recovery needs no journal replay. A crash leaves the old or the new
currentwhole, plus a staging directory or a temporary file that the nextinstalloruseremoves. The journal reaches its place by a flushed temporary and a rename, so a staging directory holds the whole journal, the temporary of one that never finished, or nothing. A directory inversions/without its record, a pointer or journal of an unknown format, or a symbolic link in the home stops the command with the cause; it removes only what the install itself recorded, and of the temporaries only the names it generates. - A dead installer does not stop its pnpm. The install passes the real
path of its staging directory to pnpm as
--dir. The nextinstallresolves the home to its real path once, before it takes the lock, so a home reached through a link (/tmpfor/private/tmp) matches, and the checks of a leftover staging directory, the process check and its removal all name the same directory. It clears the directory only afterpgrepshows no process with that argument. A pnpm that still runs, or a process list the command cannot read, stops it with nothing changed. This covers pnpm and any shim that passes the argument on, not a descendant that drops it. - Limits. The Node.js binary that ran
release useis pinned. A build that does not declare the start guard in itsstate checkcannot be selected, so a build older than this change stays installed and unused. Release files are trusted as far as read access to the GitHub release (RL5 adds signatures), and code running as the same user is out of scope: a process of the same OS user that rewrites the Maia home between two steps of a command is outside what the installer defends against. It refuses the inconsistent state it can detect. Not built, Planned: database restore, switches that migrate, serving a second profile from one home, pruning, moving the home,maia update(RL4), a Linux service unit (RL3), auto-update, remote hosts.
Install a remote Linux Node from the npm packages
The npm packages already hold what maia node install needs: @maiaos/node
carries maia-node.release.json, the bundle and the linux-x64 process host in
dist/bin, and @maiaos/cli beside it supplies maia.mjs for the Node's
worker MCP. No staging step is left; install both in one global prefix, on a
linux-x64 machine with Node.js 24.22.1.
# pnpm (copy, never hard-link: a hard-linked process host is refused at start)
pnpm add -g --config.package-import-method=clone-or-copy @maiaos/cli@canary @maiaos/node@canary
maia node install <ssh-host> \
--release "$(pnpm root -g)/@maiaos/node/dist/bin/maia-node.release.json"
# npm
npm install -g @maiaos/cli@canary @maiaos/node@canary
maia node install <ssh-host> \
--release "$(npm root -g)/@maiaos/node/dist/bin/maia-node.release.json"
- Same version. The install refuses an
@maiaos/cliwhose version differs from the release, naming both; reinstall both packages together. The check reads the package'spackage.json; the manifest pins no CLI digest. - Path as given. Pass the
root -gpath, not its real path: under pnpm the real path sits in.pnpm/with noclibeside it. - Staged wins. A
maia.mjsstaged beside the bundle is used without that version check, as before. - Runtime default.
maia node installalways addresses this machine's Node, which reads the file on this machine.--node-runtimedefaults to the Node.js that runsmaiawhen that is linux-x64 24.22.1. On any other machine pass a linux-x64 24.22.1 build with--node-runtime <file>; without it the command refuses and says so. The probe of the remote host still checks that the runtime runs there.
Update one machine to a channel or a version
Source candidate (D-140); darwin-arm64 and linux-x64 (prerelease only). The first slice of RL4: one offline command installs a channel's newest build or an exact version and selects it for the stopped profile. It adds no Node, service or restart; the running Node picks the new version up at its next start.
maia-node release update canary --profile default # preview: installs, selects nothing, prints <id>:<digest>
maia-node release update canary --profile default --yes # install if needed, then select
maia-node release update canary --profile default --confirm <id>:<digest>
- Two steps. The preview resolves a channel to one exact version (the only
network call; an exact version needs none), checks the disk, installs the
version as
release installdoes and writesupdates/<id>.json(mode 0600) with the target, the digest of the plan, the whole selection it expects (previous version included), the canonical home, the database's real location (every link resolved, the part that does not exist yet kept as written; a link that leads nowhere or any other failure refuses the preview) and an expiry of ten minutes.--yesruns both steps.release usekeeps its own plan and digest. - Confirmation.
<id>:<digest>. The id is new for every preview and names one record; the digest is the plan digest ofrelease use. A confirmation needs the same OS user, the record's target (by request or by exact version) and an unexpired record. Applying rebuilds the plan offline from the saved version under both locks. If the plan digest, the selection (current, previous, profile, interpreter), the home or the database's location differs from the record, the record is abandoned and the command refuses; a fresh preview and confirmation follow. A confirmation that was applied reports its receipt and changes nothing; when the selection has moved on since, the receipt sayssuperseded. Arelease usethat switches the selection ends every unfinished update first, so no older confirmation can undo it (the value comparison still guards a pointer that something else wrote). - Reconcile first. Under the install lock, before any channel lookup or
install, the command reads the records once. An unfinished one (
applying) whose selection landed is marked done, and a retry for its target gets that receipt: no channel lookup, no other selection. Otherwise--yes, or its own confirmation for the same target, finishes it offline on its saved version, whatever the channel names now. Any other request refuses and names it, with nothing started. Only a request with no unfinished update resolves and stages.release statusnames an unfinished update. - Disk. The sizes are provisional (D-140): 2 GiB for an install, 1 MiB for
each commit write and a 512 MiB reserve that each filesystem keeps free.
The check covers
versions/, the home,bin/andupdates/, adds the paths that share a filesystem and tests each filesystem alone. A new attempt first clears the leftover staging of an interrupted install (the installer's own ownership checks apply), so that space counts. The check runs before the download and again, with both locks held, before the commit. A disk that fills during the commit fails withrelease_failed, leaves the recordapplying, and the same command finishes it later. - Refusals. A profile whose data the target would migrate stays refused
(
migrates,newer,diverged,unreadable), as do a running Node (node_running, exit 75) and a home bound to another profile. - Limits. No database backup, restore or migration, no restart or health
verification, no pruning of installs or of old
plannedrecords, no update check or policy and no remote hosts. The disk sizes come from no measured install. Same-user hostility is out of scope, as forrelease use.
One home: ~/.maia
Planned for RL3 (decision). Everything Maia keeps
for a user lives in ~/.maia (or MAIA_HOME) on every OS: installed versions,
settings, accounts, each Node's state and its runtime files. A project keeps its
versioned intent in its own .maia/ folder.
Today the files are split between ~/.maia (agent credentials, settings
and home) and per-profile OS state, observability and runtime directories; the
filesystem contract lists
them.
RL3 moves them into this layout:
~/.maia/ mode 0700, on a local disk (SQLite WAL needs one)
layout.json layout version; migrations run step by step
config.toml user settings: channel, update policy
auth.json model-provider credentials, 0600, shared by profiles
bin/maia the command on PATH; runs `current`
current -> versions/<v> the version new commands use
versions/<v>/ one immutable install per version, marked complete
versions/.staging-*/ download and verify here, then rename into place
runtime/node-<v>/ the pinned Node.js runtime, shared by versions
profiles/<name>/ one per Node profile (`default`, `linux-test`, ...)
node.json this Node's active, previous and pending version
state/ database, worker-account roots (accounts/), migration-backups/,
and backups/<update-id>/ before updates
observability/ logs and events
agent/ the agent home (today `MAIA_AGENT_HOME`)
run/<hash>/ connection descriptor, bearer token, instance lock
cache/ safe to delete (CACHEDIR.TAG)
<project>/.maia/ versioned intent: rules, Skills, routines (committed)
- Versions live inside the home. Install, update and rollback are directory
operations in one place; removing Maia is removing
~/.maia. A development or test install is another version and profile in the same home, not a second home such as~/.maia-dev. Isolated automated tests still pointMAIA_HOMEat a temporary directory. - A service runs an exact version. The LaunchAgent or systemd unit names
versions/<v>/bin/maia node serve --profile <name>, nevercurrent, so a CLI update never changes a running Node's version. Onlymaia updatemoves a Node, through the steps under Updates. With RL8 the unit starts through the home's launcher, still pinned to<v>, so it can refuse a revoked version (security releases). - Retention. Keep
current, every profile's active, previous and pending versions, and any version a live process runs; remove the rest after a successful update, under the install lock. A Node.js runtime goes when no kept version uses it. - Runtime files.
run/<hash>/holds the owner-only connection descriptor, bearer token and instance lock; a stale descriptor or token is replaced only while holding the instance lock. - Project and user scope. Settings in
<project>/.maia/apply to that project and win over~/.maia/config.toml. A project.maia/never holds secrets, runtime state or installs. The home directory is never treated as a project, so~/.maiais always the user home. - Migration from the split layout (automatic, on the first start of a
version that knows the new layout, with no Node running): take the install
lock and each profile's instance lock; copy each profile's database with a WAL
checkpoint and
VACUUM INTO, check its integrity, copy its observability files, write a receipt; rename the old directories to*.migrated-<time>instead of deleting them; writelayout.jsonlast. If both layouts hold live data, stop and report it rather than choosing one.
Install
Planned for RL3. One command, identical on macOS and Linux, run locally or over SSH:
maia-install [--version <exact>]
It installs a private, pinned Node.js runtime and the requested Maia version
(the newest canary by default) into ~/.maia/versions/<version>/ (nothing global, no sudo), verifies npm
integrity and registry signatures, points ~/.maia/current at it, links
~/.local/bin/maia to ~/.maia/bin/maia, and registers the Node as a user
service pinned to that version: a LaunchAgent on macOS, a systemd user unit on
Linux (Delegate=yes, DelegateSubgroup=node, lingering enabled; Linux 6.12 or
later for worker confinement). It ends with maia status. Running it again is a
no-op or an update. maia remote install <host> runs the same installer over
SSH.
Updates
D-196 changes this plan: Sessions keep running through
an update instead of draining, the switch rewrites the unit to the exact
release path instead of moving current, and auto is ZU2a's updater
(ZU0–ZU3). The steps below are
RL4's original design and are revised when ZU2a lands.
Planned for RL4. The offline release update
covers install and select on a stopped profile; the rest below is not built.
- Check. The Node checks the
canarydist-tag once a day with jitter, caches the answer and reports it innode.status; offline checks stay silent. The CLI prints a one-line notice from that cache in interactive terminals only.MAIA_NO_UPDATE_NOTIFIER=1turns the notice off. - Policy. Per install:
auto,notify(default) oroff. It lives in Node runtime configuration; changing it goes through preview, confirmation and a receipt. Until RL6's seamless updates,autoapplies only when no Session is running. Updates follow canary builds only (D-202). - Apply (
maia update [--version], previewed and explicitly confirmed like every consequential mutation):- Install the target side by side in
~/.maia/versions/<version>/and verify it (integrity, signatures,maia --version,maia doctor). - Preflight: free disk for the database backup, service manager present, no newer state than the target can read.
- Drain: stop admitting Sessions, let running turns finish up to a deadline, checkpoint.
- Back up the database to
profiles/<name>/state/backups/<update-id>/, point the service at the new version, restart it, then movecurrent. - Verify through the handshake that the running Node reports the target version and is ready. Otherwise switch back, restore the backup, restart, and record the failed version so it is not retried.
- Write a receipt. Keep the two previous versions; remove older ones.
- Install the target side by side in
- Remote Nodes use the same steps through
maia node update --host <host>. - Updating never happens inside a foreground Node; a declined restart leaves a
pending-restart marker that
maia statusreports.
Versions on one machine (planned)
RL3 and RL4. One machine moves between exact canary versions, and back. Switching moves a pointer and never deletes data. The first slice, a manual switch that refuses data it cannot read, is built.
maia update --version <exact>installs one build;maia rollbackreturns to the previous version and its data. There is no channel to choose (D-202).- A switch names its exact target, and the data decides whether it is safe.
- Every switch follows Updates: side-by-side install, backup before migration, restart on the exact version, verify, roll back on failure. An older build refuses newer data and names the fix.
- Acceptance: a release job takes seeded state through an exact canary, an older exact canary and a rollback, checking version and data.
Seamless updates (planned)
Superseded in part by D-196: Sessions keep running through an update instead of pausing, and ZU2a's updater is the one automatic path (ZU0–ZU3). The pause-based design below stays as the record of RL6's original scope.
RL6 removes RL4's need for an idle Node: an update pauses work, switches versions and resumes it, so nothing acknowledged is lost. The single-Node path needs RL4 and S70's base slice; pools also need MN4 and S66, and the harness handoff needs S60.
- Policy.
autoapplies inside a maintenance window set per Node, and outside it only notifies. Setting the policy and window is previewed, confirmed and receipted, and that confirmation covers each update the policy applies; every update still writes its own receipt. - One Node. After install and preflight (RL4 steps 1 and 2), the Node
pauses every running item by exact ID through one S70 pause with the
update's deadline. Before the backup it raises a durable mutation fence:
until the update commits or rolls back, every new mutation is refused before
it is acknowledged, with the retryable error
node_updating, so nothing accepted after the backup can be lost; reads continue while the old process runs. The fence runs after authentication and the idempotency check: a replay of a committed command returns its original receipt, a reused key with different input getsidempotency_conflict, and anode_updatingrefusal consumes neither the key nor a confirmation, so the same command can be retried after the fence lowers, subject to current authorization, expiry and revision checks; a confirmation that expired or went stale meanwhile needs a fresh preview and a person's confirmation. The Node then backs up, switches, restarts and verifies, lowers the fence and releases the pause, so Sessions resume on their native sessions. A failed verify switches back, restores the backup, restarts the old version, lowers the fence and releases the pause there. The receipt names the version, the pause and each item that did not resume. - Emergency stop. A person's S20 hold, its preview and its confirmation, is the one write path the fence admits. The updater records the preview, the confirmed hold and its receipt in the update's backup directory, outside the database a rollback restores, aborts the update (switching back and restoring if it had switched), applies the hold and its receipt to the running database and only then lowers the fence. The pause stays in place, so held work never resumes on its own; the person releases the hold and the pause.
- Clients. The CLI and the SDK copy it bundles ship in the same version
directory and follow
current; Desktop updates through RL5 after its Node. A client connected across the restart re-runs the handshake (RL2), re-subscribes from its last cursor and retries a refused or in-flight mutation with its idempotency key, so a retry returns the original receipt or is accepted once. An application that pins@maiaos/sdkitself updates through its own dependency workflow: the handshake tells it when it falls below the Node'sminPeerMinor, the skew policy keeps it working until then, and a removed operation keeps a deprecation window of at least two minor versions. - Pools. After MN4 and S66, a pool updates one Node at a time, starting with a pilot Node: cordon it (S70), let its Sessions finish or move (S66), update, verify, then uncordon. The rollout stops at the first failure and rolls that Node back; Nodes stay within one minor version of each other.
- Harness executables. Codex, Claude Code and Pi keep their recorded
path and SHA-256. A new version needs a person's approval
(
maia integration planandapply), then rolls out per Node the same way.maia connection detect --refreshshows when a newer version exists and proposes the vendor's update command; Maia runs no vendor updater (D-148). A Session whose native session the new version cannot resume uses the S60 handoff after S60 ships, unless it has an unresolvedoutcome-unknowneffect, which keeps it fenced (D-023). - Schema. Migrations expand before they contract, so the previous release can still read the database for a rollback. A migration that cannot be undone that way requires the backup restore and says so in the preview.
Acceptance: an auto update with running Sessions loses no acknowledged turn
and repeats no effect; a turn sent after the backup is refused with
node_updating and, after a failed verify, accepted once on the old version
when retried; during the fence an identical replay returns its original
receipt and a conflicting one gets idempotency_conflict; a hold confirmed
after the backup, including during a failed verify, aborts the update and
leaves its targets held and paused, with the hold and its receipt surviving
the rollback; a confirmation that expires during the update is refused and
needs a fresh preview; every paused item resumes, or the receipt names why; a failed
verify leaves the old version running with its work resumed; a Session with
an unresolved effect is never handed off; a pool rollout never has two Nodes
updating at once and stops at the first failure; clients within the skew
policy keep working across the restart; a harness update without a person's
approval never runs.
Security releases (planned)
RL8. A fix for a vulnerability or a compromised dependency reaches every install within hours, on the one channel (D-202). The Node owns update authority; protocol entries come first, then the SDK, Node and CLI.
- Mark. The release plan marks a release
securitywith a severity and an advisory ID (the GitHub advisory ofSECURITY.md). It publishes its tested bytes, never skips a gate, and is installable and announced as the newest canary within 4 hours of the merge (release.security.install.h); the fixed build is never rebuilt. - Feed. One signed document: a sequence number, an expiry, each
fixed version with its package digests and each revoked version with its
advisory ID. The protocol owns the schema; the Node and the CLI share one
verifier.
- A bad signature rejects the whole feed (no update, no revocation). A feed expires 72 hours after signing; CI re-signs it daily.
- The Node keeps the highest accepted sequence and every revocation it has seen in its state, outside the cache; a restore or an older copy never lowers them, and a lower sequence is rejected.
- Past its expiry the feed is
advisory_stale: urgent innode.statusand every CLI command, every known revocation still enforced, and never the source of an update target; whether work continues is the owner's policy. - Policy makes a revoked version warn or refuse to start. Every start goes
through the home's launcher (
bin/maia, outsideversions/), which checks the feed before it runs the exact version: RL3's service unit names the launcher pinned to<v>, never the version's own binary. Stopping a revoked Node that is already running comes from that launcher or the Control Node through the OS supervisor, never from the revoked code; its reach is an owner decision. - Limit: this stops a vulnerable version, or a compromised one before it runs. Code that already ran as the profile's OS user can change anything that user owns, the launcher included; recovering from that is the runbook's job, not the feed's.
- Update. The Node refreshes the feed on start and hourly with jitter; the
CLI on any command when its copy is older.
- An organization or Workspace owner sets the security policy (
auto,notify,off) through preview, confirmation and receipt (D-124). autoruns the Updates apply steps (RL6's once shipped): drain, switch, verify, roll back on failure, receipt. Otherwise the update is urgent innode.statusand every CLI command, whateverMAIA_NO_UPDATE_NOTIFIERsays.- A Control Node updates its fleet by exact Node ID, never
all: a canary Node, then waves, stopping at the first failure; an S20 hold pauses it (MN1, MN4, S66).fleet.security.patched.hmeasures feed publication to the last enrolled Node on a fixed version.
- An organization or Workspace owner sets the security policy (
- Compromised dependency. A runbook as code under
scripts/release/lists the Maia versions whose SBOM holds an affected package version, pins or replaces it, cuts a security release and drafts the revocation, which a person confirms before the feed is signed; a dry run on a seeded fixture names exactly the affected versions. A daily CI job checks the lockfile and each shipped SBOM against a public vulnerability database (GitHub-native features andpnpm audit, DX4) and opens the runbook on a new high or critical finding; no new hosted service without the owner's approval. - Install path. CI, the installer (RL3) and
maia updatekeep a frozen lockfile,--ignore-scripts, integrity checks and the 7-day release age for third-party packages; a check fails if any path drops one. Exclusions follow D-016 and D-065 until the owner decides DEP3's stricter form: Maia's own scopes, or one exact version with a reason and an expiry.
Compatibility handshake
Planned for RL2 (decision). Every connection
(CLI, SDK, desktop, mobile, MCP, and Node to Node) starts with node.hello
before any other operation. Protocol versioning rules are in
contracts.
- Request: client kind, version, channel and commit; protocol major and minor; the capabilities it needs.
- Response: Node version, channel and commit; protocol major, minor and
minPeerMinor; capabilities derived from the operation registry ({name, version}); the pending update, if any. - Rules: a different protocol major is refused with
protocol_incompatible, naming the side that is behind and the fix (maia update, ormaia node update --host <host>). Within a major, each side must meet the other'sminPeerMinor. The SDK refuses an operation the Node does not advertise before sending it. - Skew policy: a Node is never older than its clients' minimum; clients may be up to two minor versions older than the Node; Nodes in one pool stay within one minor of each other. Update order: remote Nodes, then the local Node, then clients.
- Tolerant readers: read and handshake responses mark additive fields and
enum members forward-compatible (they decode to an explicit
unknown), so an additive change never makes an older client report a healthy Node as unavailable. Security-, identity-, authority- and mutation-bearing objects still reject unknown fields (contracts). - Registry diff in CI: the operation registry is compared with the last released snapshot under the compatibility policy.
Desktop
Planned for RL5. The Tauri app uses the signed Tauri updater with one canary feed. The signing key lives only in CI secrets. Because the source repository is private, feeds and installers are served from a public release location. The app shows the Node's handshake result and offers the same update flow for the Node it is connected to. The release host and signing identities are pending owner decisions (decision log).
Imported from Maia OS docs at 3dc3e07