Maia operations
Status: source candidate for one local Node per profile on a loopback
HTTP/WebSocket listener with a bearer token, the SQLite store with an
integrity check at open and a verified backup before each schema migration,
NDJSON diagnostics, the observe.events stream, session observe, the
Linux cgroup v2 process host, and the remote Node install
with its SSH stdio bridge (S10). Planned: emergency hold and kill switches (S20),
the execution-only process and enrollment (MN0–MN5), the master VPS
deployment, backup and restore (RL4 and the master VPS host qualification),
the live observation plane (R1, S12), and AI-outage recovery (R10). Evidence
JSONL, retention and the SLO gates are decided but not yet in a roadmap slice.
This document owns deployment, storage, identity, observability, recovery and release contracts shared by Maia Node and Maia Swarm. Operation schemas belong to the protocol registry.
The operational goal is no unobservable authoritative transition: every accepted command, state change, activation, attempt, effect, message, Routine tick, placement, provider attempt, confirmation and recovery decision has a durable event or receipt and a live redacted projection (decision). Secrets, hidden reasoning, raw audio and policy-forbidden content stay invisible.
Supported v1 topology
V1 runs one authoritative Control Node and zero or more disposable Execution Nodes (decision):
CLI / TUI / SDK / mobile / voice / A2A
|
authenticated operations
|
Control Node + Swarm services
SQLite + JSONL + artifacts
|
fenced assignments and events
/ | \
local worker Execution Node external executor
- The Control Node is the only writer of durable Project, Session, activation, queue, reservation, assignment, event, effect, receipt and trace-outbox state. Swarm runs in its process as Effect services.
- Execution Nodes run workers, hold expiring assignments and return fenced events. They keep no copy of control truth.
- There is no leader election, SQLite replication, distributed transaction or hosted database, and no Control Node high availability. Recovery is a verified backup plus worker reconciliation. Extracting Swarm or the store needs a decision, a second real consumer or a measured write bottleneck, and an online migration that keeps operation, event and receipt semantics.
The requested deployment is an always-on master VPS Control Node with optional VPS-local workers (the first version runs none on the Control Node's host, D-150), the full Desktop client, an enrolled execution supervisor on the user's computer and further Execution servers. It is one control domain, not federation (roadmap, DF-21). Before launch, qualify the VPS distribution and architecture, managed secret store, service ownership, containment, private transport, backup, update and recovery, and the laptop host profile; macOS evidence does not qualify Linux. Desktop exit, laptop sleep and VPS outage are distinct observed states; no hidden local controller replaces an unreachable Control Node. An organization Node that every person's agents connect to is the coordination hub direction; its identity model is in team Workspaces and control Nodes.
| Profile | Transport | Workers | Release scope |
|---|---|---|---|
| local foreground | authenticated loopback HTTP/WebSocket | local only | source candidate today |
| local service | loopback API; managed device credential | local only | v1 |
| tailnet control | mTLS over tailnet | local and remote Nodes | v1 candidate after MN1 |
| public internet | none | none | non-goal |
Tailscale Funnel and unauthenticated TCP are never enabled by default (decision).
Process and filesystem contract
Each profile has three private locations today. <hash> is the first 16
hexadecimal characters of SHA-256 over <uid>:<profile> (the user name on
Windows; see the
profile locator and
ProfilePaths):
| Data | macOS | Linux | Windows |
|---|---|---|---|
runtime: connection.json, auth.token, instance.lock | $TMPDIR/maia-<uid>/<hash> | $XDG_RUNTIME_DIR/maia/<hash>, else the temporary-directory form | %LOCALAPPDATA%/Maia/runtime/<hash> |
state: maia.sqlite, accounts/, migration-backups/ | ~/Library/Application Support/Maia/state/<hash> | $XDG_STATE_HOME/maia/state/<hash> (default ~/.local/state/maia/state/...) | %LOCALAPPDATA%/Maia/state/<hash> |
diagnostics: events.ndjson | ~/Library/Application Support/Maia/observability/<hash> | $XDG_STATE_HOME/maia/observability/<hash> | %LOCALAPPDATA%/Maia/observability/<hash> |
agent: auth.json, config.toml, agent/ | ~/.maia (MAIA_HOME) | ~/.maia (MAIA_HOME) | ~/.maia (MAIA_HOME) |
- Directories are
0700and files0600, owned by the Node's OS user. Security-sensitive files are read without following symbolic links, and ownership and mode are checked. Project repositories are untrusted input and never inherit runtime-directory authority. - A second Node for the same profile fails with
already_running. Node restart replaces the bearer token. The transport is described in the Node identity section. maia-node --desktop [--profile <name>]runs the authenticated local Node in the foreground (defaultprofile when omitted). A Desktop-owned child setsMAIA_DESKTOP_PARENT_PIPE=1and keeps its stdin writer; EOF or a pipe error interrupts the existing Node scope, including during startup, to drain and release runtime authority. A separate Effect deadline kills the process after 10 seconds if the drain stalls, which needs a responsive event loop. A drain that finishes after SIGINT, SIGTERM or a closed pipe exits 0, so systemd logs no failure for a restart; a failed drain or startup keeps its non-zero code. The process then gives stdout and stderr readers up to 5 seconds to take queued output, and never waits past the 10-second deadline that a closed pipe starts; a signal does not cut that wait short, and output still queued at the deadline is lost. Without that variable, closing stdin does not stop the Node; attached local services and remote Nodes keep their own lifetimes.runtimeRootOverrideco-locates the three profile locations in one directory; it does not migrate existing state.MAIA_AGENT_HOMEmoves only the agent directory. RL3 moves everything into one~/.maiahome (release).- Known gap: runtime profiles and configuration resolution are not joined at
startup.
runDesktopNodetakesprofileandruntimeRootOverridebut no configuration root, so a runtime profile is not a resolved configuration profile. - Codex and Claude Code workers never get the bearer: each launch gets its
own Session credential. On Linux the process host also denies worker trees
reads of the runtime directory and
$MAIA_HOME/auth.json; macOS has no such denial (worker credential). - Model-provider credentials live in
$MAIA_HOME/auth.json, outside these directories; on macOS the Pi RPC path also writes a per-Session copy of a leased OAuth credential under the state directory (providers). - RL3 replaces this split with one home,
~/.maia, holding versions, profiles and runtime files (layout, decision).
A remote Linux host runs the Node as a systemd user unit installed over SSH
(remote Node install); a Linux controller Node uses
the unit recipe until the local installer, the
LaunchAgent on macOS and the one-home layout follow with RL3
(install). The Linux
process host already needs a delegated cgroup v2 subtree and Landlock signal
scoping (Linux 6.12 or later); without it, worker launch fails closed with the
landlock_unavailable diagnostic
(decision). Hard containment bounds the worker
tree's resources and cleanup; it does not isolate a hostile worker from the
files and services of its OS user. Windows is not a supported v1 host: only a
Job Object source candidate exists for the managed Pi worker, and vendor
integrations fail with command_not_implemented. The Windows CLI and SDK (RL7) are
planned; they connect to a Node on macOS, Linux or WSL2.
Target shutdown order: stop admission, mark draining, expire or transfer assignments, interrupt bounded local workers, flush required events and receipts, checkpoint the evidence outbox, close streams, close SQLite, remove the endpoint descriptor, release the lock. A forced shutdown is detected and reconciled at the next start.
Linux controller Node
Documented recipe; the local installer that registers this unit is RL3
(Planned). It keeps
one Node running across logout and reboot with hard containment: the unit
delegates a cgroup subtree to the user (Delegate=yes) and starts the Node in
a node subgroup (DelegateSubgroup=node), as the
Linux host requires. It needs Linux with systemd 254
or later (DelegateSubgroup=, as the remote install also checks; see
systemctl --version) and Node.js 24.22.1.
The unit is maia-node-<profile>.service; the default profile gives
maia-node-default.service. That is the same unit name and location that
maia node install writes for that profile, with different content. node install refuses to overwrite this unit because it lacks the managed header, so
run one or the other for a profile, never both. The unit mirrors the one
maia node install writes (services/node/src/remote/host/unit.ts), so the
two share every directive. UnsetEnvironment= keeps a NODE_OPTIONS in the
user manager's environment from reaching the Node. A user unit gets a minimal
PATH, so ExecStart= holds absolute paths only (the Node.js binary, then the
maia-node.mjs launcher) and the unit sets PATH itself: the directory of the
installed maia CLI, which the worker MCP launches by name, the Node.js
binary's directory, then the system directories, as the remote unit's PATH
ends. The profile is set in ExecStart. The unit file takes values without
escaping, so a space would split a path and % would start a specifier; the
recipe stops before writing the unit if a path holds anything outside
[A-Za-z0-9._/@-]. That is the set the remote unit renderer accepts, plus
@ for the @maiaos scope and minus :, so no path can split PATH. Each
value must also be an absolute path to an existing file.
MAIA_HOME does not place the Node's own data. It holds the release tree,
maia login and the agent's files (auth.json, config.toml, agent/), and
its default is ~/.maia; the unit sets it so that a user manager with another
value cannot move them. The unit does not set XDG_STATE_HOME, so the Node
keeps its data in the default folders that the
data table lists for Linux.
What maia does on Linux today: maia-node release install and release use
(release) write files only, the version tree and the
current pointer. They register no service and start nothing. The recipe adds
the service: start at login or boot, restart on failure, and hard containment.
Save this as maia-node-unit.sh and run sh maia-node-unit.sh. It is a
script, not a block to paste: a failed check stops it with a message and does
not close your terminal. It sets umask 022 first, because many Linux logins
default to 002 and the Node refuses to start a worker package that group or
others can write. It also refuses a Node.js binary that group or others can
write, as the tool caches of some CI images are, so use a Node.js installation
that only you or root can write.
set -eu
umask 022 # installed files must not be group-writable
PM=pnpm # or: PM=npm
n="$(systemctl --version | head -n 1 | cut -d' ' -f2)"
[ "$n" -ge 254 ] 2>/dev/null || { echo "systemd 254 or later is required; this is ${n:-unknown}" >&2; exit 1; }
# pnpm must copy files: its default can hard-link the process host, which the Node refuses at start
if [ "$PM" = pnpm ]; then
pnpm add -g --config.package-import-method=clone-or-copy @maiaos/cli@canary @maiaos/node@canary
else
npm install -g @maiaos/cli@canary @maiaos/node@canary
fi
NODE_BIN="$(node -p process.execPath)" # the real binary, never a version-manager shim
[ "$("$NODE_BIN" -v)" = v24.22.1 ] || { echo "Node.js 24.22.1 is required; this is $("$NODE_BIN" -v)" >&2; exit 1; }
MAIA_BIN="$(command -v maia || true)" # must be a file path, not a function or alias
BUNDLE="$($PM root -g)/@maiaos/node/dist/bin/maia-node.mjs"
for value in "$NODE_BIN" "$MAIA_BIN" "$BUNDLE"; do
case "$value" in /*) ;; *) echo "not an absolute path: $value" >&2; exit 1 ;; esac
case "$value" in *[!A-Za-z0-9._/@-]*) echo "unsupported character in: $value" >&2; exit 1 ;; esac
[ -f "$value" ] || { echo "missing: $value" >&2; exit 1; }
done
UNIT_PATH="$(dirname "$MAIA_BIN"):$(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin"
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/maia-node-default.service <<EOF
[Unit]
Description=Maia controller Node
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
WorkingDirectory=%h
Environment=MAIA_HOME=%h/.maia
Environment=PATH=$UNIT_PATH
ExecStart=$NODE_BIN $BUNDLE --desktop --profile default
Type=simple
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillMode=mixed
Delegate=yes
DelegateSubgroup=node
UMask=0077
LimitCORE=0
NoNewPrivileges=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
UnsetEnvironment=NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS MAIA_NODE_TEST_MODE MAIA_NODE_TEST_RUNTIME_ROOT
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now maia-node-default.service
Then enable linger once, as a person: sudo loginctl enable-linger "$USER".
Without it the user manager stops at the last logout. Agents and scripts never
run it.
Check it:
systemctl --user is-active maia-node-default.service # active
maia status # the Node answers; containment is hard
loginctl show-user "$(id -u)" -p Linger # Linger=yes
- Logout and reboot. With linger the user manager starts at boot and runs
the unit with no login. After a reboot,
systemctl --user is-active maia-node-default.serviceover SSH answersactivebefore any session opened. - Uninstall.
systemctl --user disable --now maia-node-default.service, then remove the unit file. The Node's data stays in the folders of the data table, andMAIA_HOMEkeeps the release tree, login and agent files. - Another profile. Change
--profileinExecStartand name the unitmaia-node-<profile>.service. - Prerequisites. A host that lacks cgroup delegation, Landlock, bubblewrap
or user namespaces says so in
maia status.
Remote Node install
Status: source candidate (S10 and S14, decisions and D-080), run end to end on the owner's VPS (Ubuntu 25.10, Linux 6.17, systemd 257). The local Node registers remote Nodes and runs their installs; the CLI drives it:
maia node install <ssh-host> --release <linux-x64 stage>/maia-node.release.json --node-runtime <node 24.22.1 linux-x64> \
[--name <name>] [--root ~/.maia] [--remote-profile default] [--keep 3] [--codex-bin <path>] [--claude-bin <path>] [--yes]
maia node register <ssh-host> [--launcher <absolute path>] [--name <name>] [--remote-profile <name>] [--dry-run | --confirm-stdin]
maia node list [--all]
maia node remove <node> [--purge] [--yes]
maia --node <name> <command> # status, session …, mcp, login codex|claude
node install probes the host through the local Node (nothing changes there),
shows the plan with the pinned executors and any change from the last
install, asks for confirmation and waits until the Node is ready; the
confirmation binds the plan digest, so a host or artifact that changed in
between stops the install before any write. node register records a Node
that already runs on the host, whoever started it
(D-176): the local Node opens the same SSH bridge
--node opens and runs the bridge launcher (the program that starts
maia-node bridge there). Without --launcher, it reads the remote HOME
once and uses install's layout, ~/.maia/profiles/<profile>/bridge. It shows
the launcher and the greeting it read (profile, instance and version), and
refuses an untrusted host key, a bridge that speaks another protocol, a
profile other than --remote-profile, a name in use (local included) and
a host and launcher another name already holds. The confirmation greets
again and refuses if another Node instance answers, for example after a
restart. A registered Node has no install state: node remove forgets it
and leaves it running, --purge and node install under its name refuse,
and login refuses because Maia pinned no executors there. node list adds what the CLI
observes now: reachable (the Node's lifecycle, pressure and tree use, pid and
bridge latency), stale (another
release answers), unknown (no answer in 12 s), unreachable with the
reason, or not_checked. node remove uninstalls and keeps the profile's
state; --purge deletes it too and the confirmation says so. --node
routes a command through one SSH bridge per invocation, fails closed and
never falls back to the local Node; maia --node <name> login codex|claude
runs the pinned vendor sign-in on that host with ssh -t, so the
credential stays there. The Node executable keeps its own commands:
maia-node remote probe (<ssh-host> | --local) [--root ~/.maia] [--profile default] [--release <file> --node-runtime <file>]
maia-node remote install (<ssh-host> | --local) --release <linux-x64 build>/maia-node.release.json --node-runtime <node 24.22.1 linux-x64> [--keep 3]
maia-node remote rollback (<ssh-host> | --local)
maia-node remote uninstall (<ssh-host> | --local) [--purge]
maia-node remote pause [--reason <text>]
maia-node remote resume
maia-node remote update --config <updater.json>
--local replaces the SSH host with this machine: the same probe, plan,
install, drain and rollback run in a local shell. These low-level commands run
directly; the confirmation step belongs to maia node install, which wraps
them. A local shell gets only the variables systemctl --user and the shell need (HOME, PATH, XDG_*,
DBUS_SESSION_BUS_ADDRESS and a few more), so a token in your environment never
reaches a script. A machine that holds its own Node needs no SSH server and no
login key for it; the managed unit it writes is the one the
updater can then update. A host
named local stays an SSH host: only the flag names this machine.
An upgrade rolls back by itself when the new release is not healthy after the
swap: it is not ready, a readiness step fails, or a Session that was alive
before the restart is missing, failed or stopped after it (the check compares
Session IDs, so a Session must come back under its own ID). The install fails
with rolled_back and names the previous release. The rollback runs while the
install still holds the install lock, so no other install can start between
the failure and the return. A failure before the swap, a first install and a
reinstall do not roll back. Known limit: if the controller dies
mid-update, the Node may be left on the new release unqualified or holding the
install lock; read the active release with remote probe … --json and the
health with maia --node <name> status, confirm no installer is running, then
remove the lock, and run remote rollback or re-run the install. Maia does not
yet recover that state by itself. If the rollback fails too,
the install reports the original failure with the rollback's reason and
advises checking which release is active first: the rollback may have switched
before its own readiness check failed, so remote rollback could return to the
bad release. A Session that the new release failed before the rollback stays
failed; resume it with session resume.
Before the restart of an upgrade the install drains the Node over the SSH
bridge: it places a keep emergency hold
on every Project (64 targets per hold), waits until no Session is starting,
stopping, recovering or waiting-approval and no running Session has an
active or queued turn (up to 30 minutes, checking every 5 seconds), then
releases the holds and restarts at once. A started Session between turns does
not block the update. Each call to the Node opens its own bridge, and Maia
repeats a hold or release confirmation with the same command ID and proof; if
the answer never arrives, Maia looks the hold up among the active holds, so a
dropped connection does not strand a hold. A unit whose state systemd does not
report fails the install before the active service changes (the upload and
staging have already run). It releases first
because a restart under a hold would leave idle Sessions unresumed; a turn that
starts in the seconds between the release and the restart is cut like any
restart today. A wait that runs out fails busy (exit 75, retryable) with the
holds released and the active service unchanged, so a later run can try again. A Node whose unit is stopped is not drained,
and neither is a first install or a reinstall; a running Node whose bridge does
not open fails the install before the restart.
Known limits of the drain, with a follow-up ("hold-aware restart", after the
cutover): a keep hold can fail an activation that was already queued, and
the holds are released before the snapshot and the swap, so admission reopens
briefly in that gap.
remote pause is the kill switch for updates: it writes
~/.local/state/maia/update-paused on the machine that runs the install. The
path ignores XDG_STATE_HOME, because a Node's unit sets it to the profile's
state, so the shell command and the install jobs of a control Node share one
flag. While the file exists every install on that
machine fails paused (exit 75, retryable) at its start and before the
upload, the verify and the activate step; rollback and uninstall stay
available. remote resume removes the file. A flag the machine cannot read
also pauses.
A ready Node must also survive a reboot: the readiness check reads the unit's
UnitFileState (it must be enabled) and the user's Linger (it must be
yes). A Node that runs but fails either check fails the install with
reboot_unsafe (exit 69) and names the cause and its fix: enable the unit
persistently (systemctl --user enable <unit>) or enable linger as an
administrator (sudo loginctl enable-linger "$USER"), then run the install
again. The host keeps the new release without a rollback, because the
previous one has the same host settings. Maia checks the Sessions first: a
Session that did not come back rolls the update back even when the host also
fails the reboot check. A systemctl show that fails, or a unit state that
systemd does not report, fails the step; it is never read as a stopped unit.
Automatic canary updates (source candidate)
maia-node remote update --config <updater.json> is one tick of the updater;
a systemd user timer (scripts/update) runs it every 15
minutes on the machine that holds the SSH access. One tick:
- Stops with exit 0 while updates are paused (
remote pause). - Reads the exact version the
canarynpm dist-tag names from the registry.canaryis the only channel the config accepts, because Maia ships canary builds only (D-202); the updater never followslatestor any other tag. - Walks the Nodes of the config in order. A Node already on that version is
recorded. A Node behind it waits until the Node before it has run the
version for
soakMinutes(60 by default). The first Node that may update gets the release through the sameremote installas above: drain, swap, ready check, Session check and automatic rollback. - Stops at the first Node that waits, is busy (
busyexit 75 from install) or fails, so a bad release never reaches the next Node. A Node that rolled back is skipped for that version, so a later tick does not retry it; the next release is tried again. Exit code 70 means a Node failed; every other outcome exits 0 and the timer tries again.
Before anything is installed the updater downloads @maiaos/node,
@maiaos/cli and @maiaos/agent at that exact version from the registry and
refuses on any mismatch: a registry signature (ECDSA P-256 over
name@version:integrity, checked against the registry's listed, unexpired
keys; any other key type fails), the tarball URL, or the sha512 integrity.
Maia keeps no key of its own. The registry URL is a config value (default
https://registry.npmjs.org); every registry request refuses redirects.
Maia unpacks the tarball itself, with no tar process: only regular files and
directories below package/ pass, links, devices, FIFOs, absolute paths, ..
segments and set-id modes fail the whole archive before anything is written.
An archive also fails when it expands past 256 MiB, holds more than 4,096
entries (metadata headers and the parent directories a path implies count),
nests deeper than 64 directories, or carries more than 64 KiB or 64 records of
extended-header metadata. The unpacked release must report the verified
version in its own manifest before anything installs. The clock is read just
before each package's signature check.
A signature shows who published a release, not which release is newest, so the
updater refuses a target it cannot parse, one on another channel than the
installed version, and any downgrade, and it also refuses when the installed
version cannot be read; maia-node remote rollback or an explicit install is
the way back. The same check runs again on the fresh install plan, before the install lock
is taken, so a manual install that moves the Node past the target while the
release is staged makes the update fail plan_changed with nothing written. An
install that lands after that probe is caught under the lock, where the
manifest must still match the probe; the update then fails step_failed with
nothing written. Provenance attestations are not checked yet.
Before the next Node starts, the Node before it must still be running, ready and on the target version, and must report the same instance ID the updater recorded when it updated (a missing or changed ID counts as a restart); otherwise the rollout stops and the soak starts over. The registry's answer must be a release of the configured channel, checked before any Node.
The updater writes an attempt record to its state file before it installs. A
rollback that finished holds the target; a failure before the Node changed
(blocked, busy, paused, invalid artifacts) drops the record; any other
failure keeps it, so the next tick reconciles it and never reinstalls that
target on a Node whose state it cannot vouch for: a Node that never reached
the version is held on it, one that did restarts its soak. A state file that
is corrupt, too large or unreadable fails the tick (exit 69) instead of
counting as empty; the file is mode 0600.
A lock file keeps a manual tick and the timer from running at once; the second
exits 75 and the timer tries again. It sits in an update-lock directory beside
the state file. The updater creates that directory with mode 0700 (and sets it
to exactly 0700 once checked) and the lock file with mode 0600. It refuses, with
exit 69 and a message naming the path, the lock directory or the state
directory that holds it when either is a symbolic link, belongs to another user
or lets the group or others write. The check assumes the state directory's
ancestors are not writable by other users. The lock is written to a draft file
created with the exclusive flag, so a file or link already at the draft path
fails the tick without being written through, then linked into place. The lock
records the holder's process ID and start time, and nothing ever takes it
over, however old. Known limit: crash recovery is not automatic yet. A run
that died (a crash, a reboot, a kill) leaves its lock, and every later tick
exits 75 with the holder's recorded process ID, start time and the lock path
in its message. To recover, confirm that no update run is active (the recorded
process is gone), then remove the lock file by hand; the next tick reconciles
the attempt record as described above.
{
"channel": "canary",
"soakMinutes": 60,
"nodes": [
{ "name": "controller", "local": true },
{
"name": "worker",
"host": "<ssh-host>",
"root": "~/.maia",
"profile": "default"
}
]
}
A Node is local (this machine, no SSH) or a host the user's SSH
configuration reaches; root, profile and runtime (the Node.js to upload,
this process's by default) are optional. The list is configuration, not code.
The updater keeps its soak clocks and rolled-back versions in
$XDG_STATE_HOME/maia/update-state.json beside the pause flag, and logs one
line per Node to the journal (journalctl --user -u maia-update). The unit
takes a lock (flock) so a manual run and a tick never overlap.
update.started, update.done and update.rolled_back events are not emitted
by the updater yet.
The updater only updates a Node that remote install put there (--local for
this machine); a Node with no managed install fails the tick with "Maia
installed no release here".
An update restarts the Node, and with it the harness processes of its Sessions: the install drains the Sessions to idle, restarts, then resumes each Session by its ID. The Session survives, its process does not, so the Sessions an update affects are not zero until ZU1a keeps the processes alive across the restart.
Several servers share one CLI until MN1 enrolls remote Nodes
(D-141): each runs its own loopback-only Node,
maia node list reads all of them at once (a failing host never hides the
others), and maia --node <name> <command> acts on exactly one. Verify and
trust each host's key once with ssh <host> before the first install; Maia
refuses an unknown or changed key and an install that meets one stops with that
command.
Each command takes --root, --profile and --json. SSH runs with your own
configuration and keys, with BatchMode and strict host-key checking on (the
destination's key is never trusted on first use, whatever accept-new or no
your configuration says), agent, X11 and port forwarding, tunnels, local
commands and GSSAPI credential delegation off, and its own connection (never a
shared ControlMaster). These options bind that one connection only: a
ProxyJump hop is another ssh that does not inherit them, and your
ProxyCommand, SendEnv, SetEnv and UserKnownHostsFile still apply (a
UserKnownHostsFile of /dev/null defeats trust-once). When ssh fails, Maia
prints one fixed sentence (host key not trusted, refused, timed out, sign-in
refused, unreachable) and never what ssh wrote; run ssh <host> to see why.
A failed install step shows no raw command diagnostic from the host: a Maia
script names why it refused by one ID from a fixed set and Maia words that
reason. A Node that does not become ready names the command that shows its
journal (ssh <host> journalctl --user -u <unit> -n 50); Maia never reads or
relays that journal. The probe follows the same rule: an executor it cannot
pin fails with one ID from a fixed set that Maia words (a path shows only when
it is a plain absolute path), and an invalid probe line gets one fixed sentence.
Checked facts still appear in previews, output and job records: validated
paths, protocol results and host facts that fit a shape Maia knows (the kernel
shows as major.minor[.patch], a distribution as a known name and version, the
user as its numeric UID). A host fact that fits no such shape shows as
<not shown>; for an unknown distribution or a kernel suffix Maia names
cat /etc/os-release or uname -r to run on the host. remote probe --json,
remote install --json and remote rollback --json print a summary of these
facts, of the plan and of the Node's readiness (process ID and containment),
never the raw probe values, the manifest text the probe read or a local path,
and a removal or leftover from the host shows only as the plan named it or as
a count. The guarantee covers text a remote host or ssh printed, and the
bridge's greeting (see the SSH stdio bridge below). It does not cover the
free-text fields of a registered Node's protocol results, which the protocol
bounds by length alone: a blocker's message and remedy, an executor's
reason, the host facts arch, distro, home, kernel, linger, os,
root and user, the plan's maiaVersion and path, and a Node record's
error.message, maiaVersion and remaining entries. A protocol error's
message is protocol prose too, a bridge failure frame's included: it may
carry words a host chose, and Maia treats it as prose, never as a command for
the terminal. The Node that makes a result words them, human output replaces
every terminal control in them, and every --json line escapes DEL, C1,
separator and format controls.
maia --node <name> login is the other exception: it runs the vendor sign-in
on your terminal and shows that ssh session's own output. Every remote script
travels base64 behind one fixed sh -c bootstrap, so no host, path or value
reaches the remote login shell as code; a script too large for one remote
argument (120 KiB) fails locally before anything runs. Human output replaces
every terminal control character a host printed, and --json output escapes
them.
- Probe changes nothing. It reads the OS, architecture and kernel;
cgroup v2 at
/sys/fs/cgroupand any cgroup2 mount outside/sys; the controllers delegated to the user manager (cpu, memory and pids) and its owner; the Landlock ABI from the kernel (through python3, else the LSM list on Linux 6.12 or later); systemd 254 or later with a running user manager; lingering; free disk; the tools the scripts use; and what the root and unit path already hold. A host that fails any check gets every reason at once and nothing is written. Maia changes no host setting: for lingering it names the administrator's step,sudo loginctl enable-linger <uid>. - Plan is deterministic for the same facts and artifacts. The release
directory
versions/<version>-<sha12>/is content-addressed and holds the bundle, the linux-x64 process host, the@maiaos/agentmanaged-worker package undernode_modules/(the Node checks it against its embedded identity at start), themaiaCLI bundlemaia.mjswithbin/maia, the launcher worker MCP finds, which runs it on the release's runtime (D-112; the plan shows the launcher's path and the bundle's digest), andSHA256SUMS; the runtime isruntime/node-<sha12>/bin/node;currentis the link to the active release's directory. The unit is~/.config/systemd/user/maia-node-<profile>.service(one root per profile name: a unit whoseWorkingDirectory=names another root stops the plan and every script), the bridge launcherprofiles/<profile>/bridge, andinstall.jsonlists what Maia installed. Maia readsinstall.jsonby content, so key order, whitespace on its one line and added keys do not matter, and an older Maia's file keeps working. A file that does not decode (not JSON, a missing field, an ID Maia did not generate, or more than one line) stops install and probe withmanifest_invalid, and rollback and uninstall withnot_installed, and all four name one recovery: restore it from a backup you have checked, or install into another--rootwith another--profile(the old unit keeps its profile name and serves one root), then stop and remove the old unit and root yourself.--keep(2 to 8, default 3) retains the newest releases, the active and previous ones always. A release a failed install listed but never placed is pruned with the next install, and a plan that would list more than 16 releases stops withtoo_many_releases. - Install takes
<root>/.install-lockunder a nonce that every later step checks, and only whileinstall.jsonis still the one the probe read (compare-and-set); a first install writes a seed manifest with the lock, so a failed first install stays resumable and removable. A linked root,versions,runtime,profilesorrundirectory stops every step. It uploads only what the host lacks; the host checks each file's SHA-256 before moving it into place, re-checks the whole directory against the plan'sSHA256SUMSand runsnode maia-node.mjs --json versionand, from/,bin/maia --json version(both must report the release version), recording the release in the manifest before it moves anything into place. It then activates the release with one switch: it writes the release's entry scriptlaunchinside the release directory, where nothing runs it yet, then renames a fresh link over<root>/current(rename(2), atomic). An interruption before the rename leaves the old release fully active, and after it the new one, never a mix: the unit and the bridge launcher run<root>/current/launchand name no release, so the switch rewrites neither. The manifest, which keeps listing what pruning will drop until pruning removes it, follows the rename; a probe that finds the link ahead ofinstall.jsontakes the link as the active release. Before any activation Maia refuses a ledger that does not list the active or the previous release, or acurrentlink that names an unlisted release, and changes nothing. Systemd reloads before every activation, unconditionally and before the switch, and restarts the unit only when anything it runs changed or it is not running (a switch or a changed unit leaves.restart-pendingin the profile directory until a restart succeeds, so a retry after a failed reload still restarts the Node), and waits until systemd reports it active and the Node that answersnode.statusover the bridge is the unit'sMainPID, ready, with hard containment, behind a bridge of the planned version. Pruning runs after the new release qualifies, outside the rollback handler. An upgrade whose release does not become ready rolls back by itself (see above); a fresh install or a reinstall that never becomes ready stays enabled, and the error names theuninstall(orrollback) command. A second run with the same artifacts uploads and restarts nothing. - Executors are pinned, not found through a login PATH. The probe
looks for
codexandclaude(an override path alone when given, else the SSH PATH,~/.local/bin,~/.npm-global/bin,~/.bun/bin, the newest nvm Node,~/.claude/local,/usr/local/bin,/usr/bin) and follows its links hop by hop: each directory holding a link or the file, and every directory above it, must be canonical and owned by the user or root, and writable by no other user, and so must the file. Group write passes only for the user's private group (Ubuntu's775default): named after the user, its primary group, with no other member in/etc/groupand no other/etc/passwdentry using it as primary group. A script's Node.js interpreter gets the same check. The Node's vouch policy applies the same rule to the file when a person approves it withintegration plan(decision). "Pinned" means the install records each path and digest (the plan shows them) and puts the directories on the unit's PATH, which holds the release'sbin/(itsmaialauncher alone) first, then those directories, the release's runtime and/usr/local/bin:/usr/bin:/bin; the Node does not re-verify the digests when it launches a worker. An executor that fails a check, or whose name an earlier directory shadows, is unavailable with the reason, and the install goes on without it. - Unit runs
<root>/current/launch --desktop --profile <profile>, so its text names no release and a switch leaves it as it is.launchis the release's entry script: it drops the variables below, sets the release's environment and starts its bundle on the pinned runtime. The unit setsDelegate=yes,DelegateSubgroup=node,NoNewPrivileges=yes,UMask=0077,LimitCORE=0,LockPersonality=yes,RestrictRealtime=yes,RestrictSUIDSGID=yes,SystemCallArchitectures=native,KillMode=mixed(a stop sends SIGTERM to the Node alone, which ends its own workers after it records that it is stopping; systemd kills what remains once it exits or afterTimeoutStopSec; a managed unit installed earlier picks upKillMode=mixedon its next install or update),Restart=on-failureandUnsetEnvironment=NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS MAIA_NODE_TEST_MODE MAIA_NODE_TEST_RUNTIME_ROOT(launchand the version check drop them too).launchexportsMAIA_HOME=<root>,XDG_STATE_HOME=<root>/profiles/<profile>/stateandXDG_RUNTIME_DIR=<root>/run, which keep the Node's state, runtime files and any sign-in on that Node under the root; the Node still listens on127.0.0.1alone. A coding Claude Code Session binds its sandbox sockets in a folder beside the runtime directory (<root>/run/maia/ct/<profile hash>/<12 hex>), and a Unix socket path holds at most 107 bytes, so a new install refuses a root longer than 30 bytes (root_too_long; the default~/.maiais well inside). A root that already runs stays manageable for readiness, upgrade, rollback and uninstall. The runtime folder is not cleared on a reboot; each Session start recreates its temporary folder, and the folders an older release kept under the Node's state (ct/) are left in place and are safe to remove by hand. An install of the older layout (a unit that names the release) moves to this one with its first install under the new layout in three stages, so the unit, the bridge andcurrentnever name different releases: the running release gets its entry script and the link, then the bridge and the unit move to the fixed entry point (each written to a temp file and renamed, thendaemon-reload), then the final rename switches to the new release.syncruns after the entry-point renames and around the final rename, so the pointer never outlives the files it names. Thecurrentlink decides the active release.install.jsonis a ledger that can trail it by one write after an interruption; every probe reads the link's release as active, and the next install writes the ledger to match. - Rollback renames
currentback to the previous release, re-verified, with that release'sbin/on the PATH, and waits for readiness. Across a database migration it fails closed until RL4's backup restore. - Uninstall stops and disables the unit and goes on only when systemd
reports it inactive or failed (a user manager that does not answer stops
it before anything goes), then removes what
install.jsonlists; a symbolic link where a listed directory belongs stops it. Rollback and uninstall require the same plain root inside the remote home as install. It keeps the profile's state (its database) with a manifest that lists no release, so a later install of that profile adopts it.--purgeremoves the profile too, and the root when the install created it and nothing else remains. Sign-ins made on that Node live under the root (auth.json,agent/), are not Maia-installed files and stay even with--purge; uninstall lists them as remaining. - Credentials never move between Nodes: each Node signs in its own accounts.
SSH stdio bridge. ssh <host> <root>/profiles/<profile>/bridge runs
maia-node bridge --profile <profile> with the unit's environment. It
discovers the host's Node like any local client (descriptor, owner-checked
credential, node.status identity proof), greets with a ready frame
holding that status and a version that is X.Y.Z (0.0.0 for a local
build), X.Y.Z-nightly.YYYYMMDD.N or X.Y.Z-canary.N; any other version
closes the bridge as an invalid frame, and an unavailable frame shows
Maia's sentence for its reason, never its message. The bridge relays the Node API's own envelopes, one JSON object
per line, over the loopback HTTP transport. The bearer stays on the host and
nothing listens beyond the Node's loopback port. It admits 8 requests in
flight and 1 MiB plus 64 KiB per frame; a malformed or oversized frame closes
it, and end of input drains it. A request frame marked principal: "coordinator" (maia --node <name> mcp) goes to the Node with
x-maia-principal: coordinator beside the bearer, so the Node admits the
coordinator tool operations alone, as on loopback; the mark only narrows the
bearer's authority. The ready frame is unchanged by streams. A subscribe frame carries a session.attach envelope;
the bridge opens the Node's loopback observation socket with the host
credential and sends each frame as an item, then an end frame with the
reason completed, unsubscribed, lost or failure plus a code. An
unsubscribe frame, or the end of input, closes the socket and the Session
runs on. A bridge holds 4 streams beside the 8 requests; a fifth ends
failure saturated. A stream keeps its slot until its end frame is
queued for standard output, so a stalled reader of standard output leaves
up to 4 endings of active streams and up to 4 refusal replies waiting for room
beside the output queue's 16 frames. A refusal writes aside, and when 4 wait
the reader pauses for one to finish, up to 10 s (by the monotonic clock, so a
wall clock correction changes nothing) after the writer last finished or began
a frame. If the writer moved meanwhile, the wait runs on to 10 s after
that. End of input and shutdown are noticed once the waiting refusals are
written, or 10 s after the last output progress, whichever comes first. No
fixed total is promised. A client that keeps reading frees slots in time; if
the writer makes no progress for 10 s the client is not reading, so the bridge
ends (exit 69). The client then fails each pending call with outcome_unknown and each open
stream with a transport failure. On shutdown the bridge waits 5 s for
in-flight work and 5 s for its output, and the process exit adapter drains
output for 5 s more: at most 15 s, and a slow reader can lose output queued after those deadlines. The client checks
every frame with the bridge's schema and line limit before it writes it, and
refuses a stream past 4 itself. It keeps a stopped stream's ID and slot until
the bridge's end arrives. Each stream queues 64 frames; a reader that falls
behind ends lost, and the client resubscribes after its cursor. A
subscribe marked principal: "coordinator" ends failure role_denied,
since session.attach is no coordinator tool, and a subscribe for any other
operation ends failure stream_unsupported. A bridge built before streams
refuses a subscribe as an invalid frame and exits 65, which the client
reads as "update the Node". The wire is maia.node.stdio/v1; its frame
schemas are bridgeClientFrameSchema and bridgeServerFrameSchema in
@maiaos/protocol. The client side is the SDK's
MaiaTransport. Every log line goes to standard error; standard output
carries frames alone. On the VPS over Tailscale (ICMP round trip 20 ms
minimum, 30 to 36 ms average) under a load average of 45,
services/node/scripts/remote-bridge-latency.mjs measured node.status at
p50 42 ms and p95 119 to 128 ms, and a port-lease grant at p50 85 to 101 ms
and p95 233 to 244 ms (two runs of 200 sequential samples each); opening the
bridge (SSH connect, discovery, greeting) took 2.0 to 2.4 s. A first install
took 44 s, 27 s of it uploading 140 MiB; readiness after the restart took
3.4 s. An upgrade uploading 20 MiB took 22 s and a rollback 3.7 s. Through maia node add (now maia node install; S14, load average 14.7, ICMP average 43 ms) a
first install took 40 s from probe to ready, a reinstall 14 s and node remove --purge 4 s; a maia --node command takes 1.7 to 1.8 s, mostly
opening the bridge (1.7 s), and over one bridge node.status measured p50
37 ms and p95 105 ms, a port-lease grant p50 70 ms and p95 146 ms (200
samples each).
Limits. Every step re-checks the lock's nonce before each write to the
manifest or to systemd, and the unlock runs after every attempt, so a remote
script that outlives an interrupted local command stops at its next such
write; a file move already under way still finishes. A prebuilt process
host (MAIA_NODE_PREBUILT_LINUX_PROCESS_HOST) is checked against its own
.build.json alone, which proves its content, not who built it, and the
Turbo cache keys on that path, not the binary's content.
Linux artifacts from macOS. The bundle embeds the digest of each process
host it carries and starts with the one for its platform, so compile the Linux
host on a linux-x64 machine and bundle against it. The bundle carries it
beside the host this machine compiles, and MAIA_SOURCE_REVISION pins the
commit both hosts must come from:
# on Linux, with services/node/native/process-host and the build script
# (services/node/scripts/build-linux-process-host.mjs, source-revision.mjs) copied
MAIA_SOURCE_REVISION=<commit> node services/node/scripts/build-linux-process-host.mjs \
--output services/node/dist/bin/maia-process-host-linux-x64
# here, with that binary and its .build.json copied back
MAIA_SOURCE_REVISION=<commit> \
MAIA_NODE_PREBUILT_LINUX_PROCESS_HOST=<dir>/maia-process-host-linux-x64 \
pnpm --filter @maiaos/node build
Stage maia-node.mjs and maia-node-main.mjs, maia-node.release.json,
maia-process-host-linux-x64, maia.mjs (from apps/cli/dist/bin, built
from the same checkout) and node_modules/@maiaos/agent (its
package.json, README.md and dist) in one directory, and pass a
Node.js 24.22.1 linux-x64 binary as --node-runtime. The @maiaos/node
tarball of a nightly or canary carries this helper too
(D-134, a source candidate); the manual step stays
until RL3 installs from it.
Node bundle and compile cache
The Node's release artifact set is two files, named in one constant
(NODE_BUNDLE_FILES): maia-node.mjs, a launcher of about 30 lines that is
the bin entry and the manifest's application, and maia-node-main.mjs,
the bundle (manifest role application-main). A release ships both or
neither: the manifest hashes both, a remote install copies and verifies both,
and an install whose release lacks the second file fails closed.
The launcher enables Node's compile cache, then imports the bundle. A warm
cache removes the parse of the 4 MB bundle at startup (over a quarter
of an idle Node's PSS: 102 to 76 MiB at the idle gate, accepted in
benchmarks). The cache lives in compile-cache beside the Node's other
state ($XDG_STATE_HOME/maia or ~/.local/state/maia on Linux,
~/Library/Application Support/Maia on macOS), created 0700. The launcher
never blocks startup: a folder that is not a directory owned by the user with
no group or other access, or that cannot be created, starts the Node without
the cache and prints one line on stderr. MAIA_NODE_COMPILE_CACHE=0 turns
the cache off. Windows starts without it.
Integrity tradeoff: cache files carry a checksum of the source, not a signature, so they guard against stale or corrupt files, not against someone who can already write the owner-only state folder. That person could change the rest of the Node's state too. The first start of a build writes the cache (about 200 ms slower, 3 MB); later starts reuse it.
SQLite contract
SQLite holds current domain state, events, receipts and the trace outbox (decision). It runs only on a local APFS or ext4-class disk; network filesystems, synchronized folders, removable media and multiple writer processes are unsupported.
Implemented today (policy, migrations):
foreign_keys = ON,journal_mode = WAL,synchronous = FULL,busy_timeout = 1000ms,temp_store = MEMORY,trusted_schema = OFF, a 64 MiB WAL size limit, a 512 MiB database cap and an application ID check;- planned (D-188):
synchronousbecomes configurable asevents.fsync(always=FULL, the default); storage hardcodesFULLtoday; quick_checkandforeign_key_checkat every open; failure isstorage_corrupt;- a newer or diverged database fails with
storage_incompatible, and one this build cannot read (not a Maia database, a schema tag that disagrees, or an empty main file next to a-wal) fails withstorage_corrupt, each before the writable open, any backup, any migration or any permission change (Source candidate). A database that is refused keeps its bytes, permissions and ownership; start tightens an accepted file to0600only after the check passes; maia-node state check [--profile <name>] [--json]reports offline whether this build fits a profile's database:absent,readable,migrates,newer,divergedorunreadable. It takes no Maia instance lock, creates no directory and writes neither the database nor its log. It reads one consistent snapshot, including commits that still live in a-wal, under SQLite's normal shared locks, so it can delay a checkpoint while it reads; an exclusive or recovery lock held longer than the busy timeout givesunreadable. The first read of a closed WAL database creates its-shmand an empty-wal; SQLite keeps both, and later reads add nothing. An empty main file next to a-walisunreadablewithout being opened, because SQLite would delete that log. It runs no integrity or ownership check, soreadabledoes not promise a clean start (Source candidate);maia-node release usegates a switch on this check: it runs the target build'sstate checkand switches only when the verdict isabsentorreadableand the build declaresstartGuard: true, which says it checks its own start (release, Source candidate);- before a schema migration: WAL checkpoint (a reader that blocks it fails the
start with a retryable
storage_failedinstead of a stale backup), copy tomigration-backups/, verify the copy, migrate, verify again. A migration that drops content leaves astorage_scrub_pendingmarker until it has rewritten the database and scrubbed that content from the backups, so a failed or interrupted run repeats at the next start (the start fails closed until it succeeds). If one backup cannot be scrubbed, the error names it: move that file out ofstate/migration-backups/to start the Node. Maia never deletes a backup for you, since it may be your only way back. Normal use keeps at most 9 backups in the directory, well under its 64-entry bound.
Target rules:
- One Effect service owns the writer connection and serializes write transactions; bounded readers use snapshot transactions.
- A mutation writes aggregate rows, revision, ordered events, receipt, effect state and trace-outbox rows in one transaction. An external effect commits its intent before invocation and its observed outcome in a second transaction.
- A busy database returns
storage_busy; the command is never reissued without receipt reconciliation. integrity_checkruns before backup, after a suspect unclean shutdown and in deep doctor checks. Admission pauses at 90% of the disk budget; consequential writes fail closed at 95%; read-only status and export stay available while safe.- Every table has a schema version, foreign keys, unique idempotency constraints, a revision check, timestamps, a retention class and indexes for its registered queries. Cascading deletion of durable history is forbidden; archive or tombstone instead.
Evidence JSONL and artifacts
Decided, not implemented. JSONL is immutable evidence for export and evaluation, projected from the SQLite outbox; it is never a second command database. The commit and recovery protocol is owned by the Node.
The projector claims contiguous outbox ranges per owner, writes canonical UTF-8 records to a temporary owner-only segment, fsyncs it, renames it atomically, fsyncs the directory, then marks the range projected in SQLite. Each segment manifest records first and last owner sequence, record and byte counts, previous-segment digest, content digest, encryption key ID, creation time and classification. At startup:
- a temporary file without a manifest is quarantined and replayed from the outbox;
- a manifest without a database marker is verified, then adopted idempotently;
- a database marker without its file sets health to
evidence_degraded, pauses consequential admission and forbids backup; - a digest or chain mismatch quarantines the segment, holds the affected Project and requires an explicit repair operation with a receipt.
Session conversation content is held only in transit
(D-168). Other inline event content is bounded to
64 KiB; larger content becomes an encrypted
artifact after
classification and redaction. Runtime evidence never lives beside
.maia/agents or .maia/routines. An explicit export may write under the
ignored .maia/state/exports/, which Node never reads as authority.
Move a running lane to Maia (adopt)
Source candidate (S11). Use it to continue a Claude Code or Codex session in Maia with its context intact. Proven on an isolated Node: Codex end to end (adopt, resume, recall of a nonce set before the move, history read). Claude adopts and resumes only with a subscription login; see the limit below.
- Stop the lane yourself. Maia never stops a native session. Close it
in its own terminal; a Claude Code session is live while its
sessions/<pid>.jsonrecord exists. - Wait 2 minutes.
adoptrefuses a session active in the last 2 minutes. - Prepare the Node once per vendor.
- A system-default Connection:
maia connection create --name <claude|codex> --executor <claude-code|codex-cli> --root system-default. - Grant it to the Project. The preview-then-confirm pair is
maia --json connection grant set <connection> --project <project-id> --dry-run > grant.json, thenmaia connection grant set --confirm-stdin < grant.json. - Approve the vendor executable:
maia integration plan <vendor>, thenmaia integration apply <vendor> --plan <plan-id>. - A Workspace of the same repository as the session. For Claude, the session's original directory must be the Workspace's primary checkout; Codex may be relocated.
- A system-default Connection:
- Preview and confirm.
maia --json session adopt <claude|codex>:<native-id> --workspace <workspace-id> --dry-run > preview.json(add--attest-endedfor Codex, which keeps no liveness record), check the directory and Connection in the preview, thenmaia --json session adopt --confirm-stdin < preview.jsonwithin 15 minutes. The Session startsstopped. For many sessions, list them in a file and runmaia --json session import --from sessions.json --dry-run > import.json, thenmaia --json session import --confirm-stdin < import.json; a rerun adopts nothing more. Detect the installed harnesses withmaia agents detect. - Resume and check.
maia session resume <session-id> --expected-revision <n>, then send a turn asking for a fact only the old conversation holds, and comparemaia session history read <session-id>with the old transcript. The history shows the pre-adoption messages.
Limits:
import scanlistscodex execthreads. A Codex thread showsobserved: runningwhen a Codex process holds its rollout file open or was started withresume <thread-id>orexec resume <thread-id>; any other thread isunknown, so an empty--runningresult does not prove that no Codex process is running.- A system-default Claude Connection runs a turn only when the host's own
Claude config is signed in to a subscription (
claude auth loginon the Node host, as the same user and config root). A lane that runs through a gateway or settings endpoint fails the resumed Session withauthentication_failed. Planned: resume such a lane with the user's own vendor config. - A native session is adopted at most once. The adopted Session is
failedif the resume cannot start; fix the cause and resume again.
Backup, restore, and disaster recovery
Planned. Today only the verified pre-migration copy exists. Target commands:
maia node backup preview
maia node backup create <preview-id> --confirmation-token-stdin --command-id <id>
maia node backup list [--limit <1..100>] [--after <cursor>]
maia node backup verify <backup-id-or-path>
maia node restore verify <backup-path>
- A backup is previewed (schema, registry and key manifest, size, blocking conditions), then confirmed. It is an encrypted directory with a SQLite online-backup image after a passive WAL checkpoint, the JSONL segments up to the committed outbox watermark, artifact manifests and referenced blobs, schema/protocol/registry/Node/Project/key identifiers, sizes and SHA-256 digests, and a signed receipt.
- Credentials and secret-store master keys are never copied; the manifest lists the required key IDs so an operator can check recoverability.
- Default local-service policy: one daily backup; keep seven daily and four weekly; never delete the last verified backup.
- Restore verification is read-only. Restore needs a stopped Node, an empty
target, exact confirmation, a compatible binary, all required keys and full
integrity checks. It restores into a new directory, starts in
reconciliation-only mode, fences all prior assignments, marks every enrolled
Execution Node
recovery-requireduntil a fresh enrollment, reconciles external effects and switches the endpoint only after the operator applies the plan. It never overwrites the source.
| Failure | RPO | RTO |
|---|---|---|
| worker or process crash | zero committed | 5 minutes |
| Control Node process crash | zero committed | 10 minutes |
| Control Node disk loss | 24 hours | 60 minutes |
| one Execution Node loss | zero committed | 10 minutes |
Qualification injects process kill, power-loss equivalent, disk full, locked database, truncated segment, missing artifact, missing key, and interrupted backup and restore at every phase.
Execution-only process target
Planned (MN0–MN5). Remote capacity is a reduced composition of the existing Maia Node executable, not a new daemon or a second Control Node; the Node composition owns what it runs. MN0 defines no startup mode and no CLI help: the slice that adds the execution-only mode (MN1 or MN2) freezes its exclusive startup grammar and help with its own tests. Execution-only mode refuses to open the Control database or business handlers. Its OS service manager supervises only its own exact process group, never a matching name or an arbitrary PID.
The enrollment root is owner-private under the qualified platform's Maia state root, partitioned by exact Control Node and Execution Node identity:
execution/<control-node-id>/<execution-node-id>/
supervisor.lock # one supervisor for this exact enrollment
enrollment.json # public IDs, certificate metadata, secret-store reference
assignments/ # bounded reconciliation checkpoints, never control truth
spool/ # bounded unacknowledged evidence with source sequence
artifacts/ # authorized, size- and digest-checked transfer staging
environments/ # only explicitly prepared and admitted directories
- The owner, mode, no-follow, identity and lock rules of Control state apply. Private keys and repository or provider credentials belong in the qualified OS secret store, never in these files, argv, environment or logs. Neither layout gives a worker access to other runtime state or lets imports escape approved roots. Runtime files and temporary handles use the platform's private runtime directory.
- The process connects outbound to the pinned Control Node. The candidate transport is the existing HTTP operation and WebSocket observation contracts on qualified tailnet mTLS; the first version's transport is a pending owner decision (D-150). It exposes no inbound worker-control API. MN0 freezes the assignment, lease and evidence schemas, their bounds and the transport requirements; the machine-authenticated reads, acknowledgements, renewals and evidence operations arrive in their later slices (MN1 and MN2), each with its own protocol entry and tests. Notifications are not launch authority; reconnect reconciles assignment, lease, worker incarnation and outstanding effects before any start.
- MN0 also fixes spool count, byte and age limits, acknowledgement and truncation rules, lease TTL bounds and the clock error budget, and monotonic local deadlines anchored to the request the Node sent, with Control enforcing the same duration by its own monotonic deadline (bounds and semantics). Heartbeats use the five-second, four-miss placement policy.
- Lease loss forces a bounded local stop; a Control-side fence does not prove that an unreachable process stopped. Exhausted evidence storage stops new admission and triggers a bounded safe interruption or explicit uncertainty, never silent record loss. Local checkpoints cannot renew a lease or authorize a relaunch.
- Process-tree cleanup, abrupt reboot, spool corruption or loss, and Control restore must pass host-specific tests before qualification. Restoring either side is an administrator recovery that ends in a fresh enrollment (restore and rollback).
Host qualification matrix (MN0)
Four questions about a host, answered separately. An answer to one never implies another, and none is stronger than its evidence. Every remote host is Planned until MN2 supplies the multi-host transport and L2 evidence (DF-03, DF-08).
- OS and architecture (what Maia packages, per release):
darwin-arm64andlinux-x64are the platforms the release document lists.win32-x64appears in D-019 and the native process-host build scripts, but no release lists it, so it is unsupported until one does. - Containment tier (D-019, source candidate):
linux-x64has a hard tier (cgroup v2, Landlock, seccomp; Linux 6.12+ and a delegated cgroup v2 parent).darwin-arm64has cooperative tiers only, which assume non-malicious software and are not OS containment. On both, a person's Terminal runs in the interactive tier, with the person's authority. The tier bounds a process tree, not what the user's account can read. - Managed read-only qualification (
managed-read-only, no process tools): no host is qualified. D-019 does not qualify it: the tier says nothing about environment-root enforcement, secret-read denial or evidence spooling. A host qualifies after multi-host transport, DF-03 symlink, path-swap, outside-root and secret-read denials, and the process-tree cleanup, abrupt reboot, spool loss and Control restore tests above, on that host. - Coding qualification (remote builds, tests, writes): no host is qualified, and none can be until a named per-OS containment adapter passes filesystem, process, network, resource, secret and escape tests with clean-host evidence and its own decision entry (architecture). D-019's own consequence applies: a hostile worker can still read the user's files, so it is not enough alone.
Identity, enrollment, and secrets
Today. The local Node authenticates a random bearer token from the
owner-private profile runtime directory on an exact 127.0.0.1 listener. It
authenticates before reading request bodies and rejects browser-origin
requests (details).
This is source-candidate transport authentication, not the managed device
credential lifecycle. There is no remote access.
Local devices (target). Local callers bind to one Maia device record. The first local bootstrap creates the Control Node identity and an admin device after explicit OS-user confirmation. Device credentials are random 256-bit secrets in the managed secret store, shown publicly only as ID, fingerprint, scopes, issue time, expiry and revocation state.
Tailnet enrollment (MN1).
- An already authenticated admin client shows a one-time, five-minute pairing challenge. The joining Node generates its own key pair. The Control Node issues a scoped client certificate bound to Node ID, device ID, tailnet identity evidence, allowed Projects and capabilities, audience and expiry.
- mTLS authenticates the transport; Maia authorization still checks the current grant and revocation revision on every request. Rotation starts at 50% of the TTL. Revocation fences assignments and streams at once.
- Enrollment needs the verified local owner through the managed admin operation and confirmation; the profile bearer alone is not enough. First version: the D-150 terminal ceremony replaces the managed admin operation, and revoke and narrow use the local bearer until it exists.
Machine principals (MN0). A remote Execution Node gets a machine principal and a machine device record, never User Device membership or user or operator authority. Its certificate audience is one exact Control Node. Its grants cover only machine operations for its own assignments, prepared environments and evidence. MN0 adds this principal branch to protocol authentication; do not fabricate a client Session or reuse a user login to make a machine pass. Team invitations and multi-Control-Node UI are not needed for one owner's pool, but owner bootstrap, managed admin authorization, revocation and exact Project and capability grants are. Repository preparation credentials need the same secret handling even when no model provider is involved.
Secrets. Current storage and the places credentials must never appear
are in providers. The target
(decision) is one SecretStore Effect service,
macOS Keychain first, with a separately qualified Linux backend before the
master VPS or MN1 (D-155). Locked, unavailable or reset
storage is a typed blocking state; managed mode has no plaintext or
environment fallback.
Source candidate (D-139): on macOS a fresh profile
with no Connection can set MAIA_SECRET_STORE=managed once, and its gateway
keys then live in the login keychain through /usr/bin/security. The Node
records the choice in secret-store.json in the profile's state directory
before the first key, and later starts follow it: no downgrade to local files,
and no conversion of a populated profile. Keys over 2048 bytes are refused
before the Connection exists. If the keychain is missing or reset, a call
fails with a repair message and no retry; a locked keychain does the same in
a background session, while a GUI session holds the call at an unlock prompt
until the 10 s deadline and reports a retryable timeout. Enter the key again with
maia login gateway under a new name. Deleting secret-store.json is not
recovery: it orphans the stored keys. Anyone who can run /usr/bin/security
as the same macOS user can read these keys, so this is OS-account custody,
not isolation from other processes of that user. A Claude Code or Codex worker
still receives the key in its environment at launch, so this slice protects
the key at rest, not from that worker. Migration, rotation, removal and
administrator credentials are Planned. So is a short-lived credential for
one Connection and one activation, passed to a worker through its inherited
private channel and never the store locator or a reusable refresh token unless
the adapter strictly requires it.
Source candidate, Linux (D-155): on a fresh Linux
profile MAIA_SECRET_STORE=managed seals each key with
/usr/bin/systemd-creds --user into a file under secret-store/ in the
profile's state directory, and later starts follow the marker. It needs systemd
256 or later; activation fails with a typed error on an older one, and a
restart runs no tool. The key binds to this host's key, machine ID and Unix
user, so a file copied elsewhere, a replaced /var/lib/systemd/credential.secret
or a renamed user leaves every key recovery-required: enter the key again
with maia login gateway under a new name. Root and any process of the same
user can open the keys, so this is OS-account custody too. After a systemd
package update, restart the Node so it pins the new tool. Qualification on
the target host is pending.
Connections. Today a Connection is a named vendor account with state, slot cap, revision and receipts (providers). S2 adds an owner actor, Project grants and allowed capabilities. Sharing, default changes, revoke, transfer and sign-out are mutations with preview, compare-and-set, receipt and audit event. Revocation blocks new activations at once; active inference is interrupted after a 10-second grace, and uncertain external effects enter reconciliation.
Capability enablement and kill switches
Planned (S20; decision). One protocol-owned capability registry controls risk-bearing ingress and execution; the enablement record is defined in the contracts.
- Default
disabled: remote Node enrollment, A2A ingress, MCP, web egress, voice tool proposals, external executors, unsandboxed process execution and telemetry export. The baseline is the authenticated loopback Node API, bounded workspace reads and redacted local evidence. - An enablement is exact-scoped and expiring. A Project may narrow a user or Node enablement, never broaden it. Enable, extend, disable and revoke use preview, confirmation, compare-and-set, idempotency, receipt and audit event. Expiry fails closed.
Kill switches are typed revocations, not process-wide environment flags:
capability.revokerejects new matching operations at once;- Node fences matching queued and running assignments at the next safe edge;
- in-flight external effects are interrupted when supported, otherwise they
enter
outcome-unknownreconciliation; - streams receive a terminal policy-revision event;
- nothing resumes until a newer explicit enablement is admitted.
The emergency hold, a local-admin operation, targets one exact Project,
Connection, peer, Node or capability. There is no all target. A machine-wide
incident is a set of separately previewed exact targets in one bounded target
set and one receipt.
Emergency holds: implemented today (source candidate)
The local Node implements the hold for exact Projects and accounts
(Connections) through five human-only operations: hold.place.preview,
hold.place.confirm, hold.release.preview, hold.release.confirm and
hold.list. The CLI and SDK expose them; the coordinator MCP server, Agent
tools and voice do not.
- Targets. One hold names 1 to 64 existing targets, each
{ "kind": "project" | "connection", "id": ... }. An unknown identifier fails closed; there is no wildcard orall. A machine-wide incident is one hold whose preview lists every Project and account by ID: one preview, one confirmation, one receipt. A Node with more than 64 Projects and accounts needs one hold per 64 targets, each confirmed on its own. - Admission. While a hold is active, every new piece of agent work on a
target fails with the typed error
held, whose message names the hold and the target:session.create,session.spawn,session.start,session.resume,turn.send(including confirmed managed voice turns),run.create(Run dispatch) and the voice Pi relay, checked again when a prepared relay is dispatched. Each check runs inside the transaction that admits the work, behind the Node's single writer, so no admission commits after the hold's receipt. Automatic account selection skips held accounts; when every account of that kind is held, creation fails withheld. The CLI exits 75. Exact replays of commands admitted before the hold still return their original receipts, but held work never launches: an activation that a start or resume queued before the hold fails closed (activation_failed) instead of starting a runtime, and a person resumes the Session after the release. - Running work.
running: "keep"leaves running work alone: holding never stops work by inference, and turns already dispatched keep running.running: "stop"is the explicit stop: its preview lists the Sessions instarting,runningorrecoveringon the targets (at most 64), and the confirmation binds that list. In the hold's transaction each Session still running stops: queued turns failturn_rejected, an in-flight turn settlesoutcome-unknown(runtime_exited) for the existing reconciliation, the activation stops, and the Session recordssession.stoppedwith theholdId. After the commit the Node ends every stopped runtime (Codex, Claude Code and the managed Pi worker), assession.stopdoes. When a runtime has not ended within its 3-second teardown deadline, the confirmation answers retryablesaturated(exit 75) although the hold is committed; repeating the same confirmation replays the receipt and waits again, and it leaves alone a Session that a person resumed since. The stop is atomic: a Session that started after the preview, or that cannot stop, fails the whole confirmation and places no hold. Preview again, or place akeephold first and then stop Sessions one by one. - Release. Release is previewed and confirmed against the hold revision; it restores admission at once. The released hold stays as evidence. Sessions a hold stopped stay stopped until a person resumes them.
- Durability. Schema v44 stores holds, their targets and private previews. Holds survive a Node restart, and recovery never resumes held work: the activation of a Session on a held target fails closed instead of relaunching.
- Evidence. Each confirmation writes one receipt (target
hold) and one timestampedhold.placedorhold.releasedevent; a stop adds onesession.stoppedevent per stopped Session, citing the hold's command. Previews last 30 seconds, write nothing to the journal and never appear in receipts.
Measured on the developer Mac. In-process
(services/node/src/hold/application.test.ts, 30 trials per run, real SQLite,
fake executor runtime, six runs): rejecting three new admissions in turn
(create, start, turn send) after the hold's receipt took p95 0.8 to 1.6 ms,
and 32 ms in one run on a heavily loaded machine, against the 100 ms target;
a stop hold from preview to confirmed receipt with every running runtime
ended took p95 2.4 to 4.4 ms, and 140 ms in that loaded run, against the 2 s
target. Over the authenticated Node API with the built Node
(services/node/src/hold/hold.integration.test.ts, 20
trials per run, five runs), a Session creation after the hold's receipt was
rejected in p95 2.3 to 3.3 ms.
Deferred, each for a stated reason:
- Node targets. The local Node has no durable Node identity until remote enrollment (MN0) adds one; holding the single local Node is the same set of admissions as holding all of its Projects, which a bounded target set already expresses.
- Capability targets and
capability.revoke. No capability enablement registry exists yet; a hold on a capability needs that registry's exact capability IDs and enablement records. - Peer targets. A2A ingress is not implemented.
- Terminal policy-revision events on streams wait for the observation plane to carry policy revisions.
- Not admission paths. Interactive terminals,
execution.command.runand attached Session mailboxes are person-driven or passive and do not start agent work today, so holds do not gate them; worker credentials (S18a) will need them gated. Astophold does not settle terminal-backed Run dispatches; settle those withrun dispatch settle-terminal. - Hold expiry and Desktop or mobile projections.
Pause and resume (planned)
S70 adds a planned, reversible pause beside the emergency hold. A hold
rejects new work with held; a pause queues it and resumes smoothly. A hold
wins: resuming a held target fails held.
- Targets. One pause names 1 to 64 exact targets. The base slice covers
Session, Run ledger run and Project after S19, S26 and S29; each other
target arrives with its owning slice: Routine with S30 (and S50 steps),
Agent with S49, task tree with S53, Execution Node with MN4. There is no
all; pausing a whole Control Node is one bounded set that lists every Project by ID, as holds do. - Operations.
pause.place.previewandconfirm,pause.release.previewandconfirm, andpause.list, mirroring holds; CLI first asmaia pause <kind> <id>... [--deadline <duration>] [--until <time>],maia resume <pause-id>andmaia pause list. A person confirms; an orchestrator may pause and resume only its own children; an update (RL6) pauses and resumes under its own confirmation. - Pausing. Admission of new runs stops at once for the targets. A running
turn continues to its end, up to the preview's deadline; at the deadline it
is interrupted and its outcome recorded (
interrupted, oroutcome-unknownfor reconciliation). Pending questions and approvals stay pending, and answering one starts no turn until resume. The Node then stops each Session's harness process assession.stopdoes, keeping its native session reference, item cursor and worktree, and releases its port and lock leases. Session goals (S69) pause and their wall-time budget stops. - While paused. New turns for a paused Session queue under S26's bound
and error (
turn_queue_full). A paused Run ledger dispatches no task. A paused Routine records each cron tick asdeferredand queues event ticks up to its pending-tick cap; overflow isblocked, as today. An Execution Node pause is a cordon: nothing new is placed there; its running Sessions finish, or move once S66 ships, as the preview chose. - Resuming. Release is previewed and confirmed, or happens at the
--untiltime chosen at pause, with its own receipt. Each Session resumes assession.resumedoes, on its native session, re-acquires its leases (a lease another owner took fails that Session's resume withlease_conflict, reported, never forced) and runs queued turns in order. A Session with an unresolvedoutcome-unknowneffect stays fenced: release reports it and neither restarts it nor hands it to another harness until reconciliation settles the effect (D-023). Deferred cron ticks follow the Routine's catch-up rule; event ticks run in arrival order. Starts are staggered under the existing admission caps, and under S15 thresholds and the S65 quota governor once each ships. - Durability. Pauses survive a Node restart, and recovery keeps paused
work paused. Each confirmation writes one receipt and one
pause.placedorpause.releasedevent, plussession.pausedorsession.resumedper Session.
Acceptance: after the receipt no new turn, run or tick starts on a paused
target; no queued turn or tick is lost or run twice, and the ninth queued
turn gets turn_queue_full; a turn past its deadline settles once as
interrupted or outcome-unknown; after an external effect succeeds but its
acknowledgement is lost, neither a manual nor a timed release restarts or
hands off that Session; resume repeats no effect, and every paused item
resumes or reports why; a pause survives a restart; a held target never
resumes; an orchestrator pausing work outside its children gets
role_denied.
Live observability contract
Today domain_events in maia.sqlite is the authoritative journal,
observe.events streams change signals (re-checked every 5 seconds when idle),
session observe reports live activity and process usage, and the Node writes
NDJSON diagnostics (telemetry). The plane below is
the target for R1 and S12. The Effect ports (ObservationIngress,
ObservationStore, processors) are in the
Node architecture.
This section owns coverage, lanes, fan-out, limits, the read model and
commands, health, metrics and retention.
Scoped graph and coordinator disclosure
The graph snapshot contract extends this plane; it is not a new graph store. Materialize only observed relationships, with source revisions, per-owner watermarks and explicit partial or paginated coverage, never one claimed global atomic snapshot. Semantic classifier overlays are inferred and attributed, never authoritative state or scheduling edges.
Human and system-service observation scopes never flow into model Sessions. Apply the coordinator discovery ceilings before filtering, traversal, counts, sampling, export and cache lookup. A Project cannot learn that a sibling exists through IDs, logs, correlation links, errors, counts or cursors, except through the bounded peer directory when both Projects allow Workspace reach (D-166); a Workspace cannot discover other Workspaces. Parent mediation keeps full protected audit lineage but projects only admitted content. Non-disclosure never destroys provenance. Recheck revocation on replay, page fetch, evaluation and context loading. Qualification includes PF-22/DF-22 and PF-24/DF-24.
Coverage invariant
These records are mandatory and never sampled:
- command accepted or rejected and its authorization decision;
- aggregate state transition and revision;
- activation admission, queue, assignment, start, heartbeat, terminal state;
- worker spawn, exit, fence and attempt identity;
- effect intent, invocation, result, timeout, unknown outcome, reconciliation;
- message accepted, claimed, processed, dead-lettered;
- Routine tick planned, queued, started, terminal;
- budget reservation, settlement, release, overrun and provider usage;
- placement, lease, drain, handoff and stale-writer rejection;
- Connection selection, fallback attempt, rate limit and circuit state;
- preview and confirmation issue, consume, invalidate;
- voice session, turn and tool lifecycle, without raw audio;
- A2A ingress, update, cancel, artifact and replay decision;
- backup, restore, migration, retention, purge and integrity actions.
Each record carries Project, Session, activation, attempt, effect, command, receipt, actor, Node, Connection, provider and correlation IDs when they apply. Absence is a typed not-applicable value, never an omitted field that clients guess.
Node-wide streaming plane
The Control Node is the only application-facing streaming server. Every Session is observable from creation; no worker, Agent or Project enables a logger or knows that a client is attached.
| Lane | Content | Delivery rule |
|---|---|---|
authoritative | commands, revisions, receipts, effects, terminal messages, final usage | committed with the operation, never sampled; a failure pauses admission at the next safe edge |
diagnostic | text deltas, progress, debug logs, span details, metric snapshots | non-blocking bounded batches; coalesce first, then drop with a mandatory observation.gap summary |
- No subscriber, projector, evaluator, exporter or Swarm processor can apply backpressure to a worker. Fan-out happens only after commit; diagnostic emission never awaits a client.
- Overload can lower live granularity but never hide a final turn or
authoritative state. A gap record names source, range, coalesced and dropped
counts, bytes and reason, and marks coverage
degraded. - Diagnostics go to checksummed spool segments, not one SQLite row per delta; authoritative observations reuse their SQLite event and outbox rows.
Partitioning, fan-out, and bulk processing
- Records keep their gap-free
ownerSequencefor causal truth and also get a logical(partitionId, partitionSequence): 32 partitions by a versioned stable hash ofnodeId + projectId-or-ownerId. Cross-partition order is never inferred. StreamBrokersubscribes to committed partitions, not to workers. It evaluates an authorized compiled filter once per batch and shares one immutable batch across matching subscribers; one aggregate subscription can cover many Projects and Sessions. Each subscriber has its own bounded queue.- Each processor declares input schema versions, filter, partitions, batch limits, output schemas, checkpoint version and retry and dead-letter policy. Node stores one checkpoint per processor and partition and delivers at least once, so a processor is idempotent per processor, partition and sequence, and writes its projection and checkpoint atomically. Rebuild starts from an exact snapshot and watermark. A processor never mutates a Session or Project; it submits a normal registered operation with its service identity.
- No Kafka, Redis, NATS or other database in v1. Projection or fan-out may move out of process only behind the same cursor, checkpoint, authorization, redaction, gap and replay fixtures. A future external sink is an out-of-process adapter with an exact capability grant and its own checkpoint; it never becomes a Maia authority.
Initial v1 limits; policy may lower them, and raising a hard limit needs benchmark evidence and a configuration admission:
- one normalized record: 64 KiB; larger content becomes an artifact;
- worker diagnostic queue: 2,048 records or 8 MiB; coalesce at 75%, drop with a gap record at 100%;
- local ingest batch: up to 128 records or 512 KiB, flushed within 50 ms;
- subscriber queue: 1,024 records or 4 MiB; a slow subscriber is disconnected with its last resumable cursor;
- 64 live subscriptions per actor and 1,024 per Control Node;
- stream heartbeat every 5 seconds; three misses close the stream;
- one query: at most 64 Project IDs, 256 Session IDs, 16 domains and 64 event types; larger views use server-owned saved filters.
A cursor holds the topology revision, authorized filter digest, policy revision, expiry and the acknowledged sequence of each matched partition.
Live read model
Node keeps bounded read projections for global health and storage or evidence lag; Projects, Sessions, activations, attempts, workers and attachments; queues, reservations, Routine ticks and messages; effects and their reconciliation; Connections, provider attempts, rate limits, usage and spend; Nodes, leases, assignments and handoffs; voice and A2A lifecycle; and trace integrity, retention, backup and restore status.
Clients use snapshot-then-events: a snapshot carries its watermark, events start at the next sequence, and a cursor gap returns a typed reset and a new snapshot. The loopback WebSocket and tailnet WSS carry the same framed protocol; an HTTP snapshot plus SSE projection may exist but cannot change the schema or cursor. Authorization is checked at open, snapshot, replay and each policy revision; a revoked grant ends the stream and its old cursor cannot resume. The target command surface (R1) is in the CLI reference.
Today maia node monitor prints a loopback monitor URL with a short-lived token;
the target makes it an alias of maia observe health --watch, never a second
data source. Desktop, mobile and the terminal UI use the same SDK queries and
streams.
Session timeline
observe session returns one causal timeline: user, peer and Routine
messages, assistant deltas, tool calls and results, state changes,
interactions, budget usage, attempts, worker lifecycle, effects and receipts,
plus the evidence watermark and required-edge coverage. Hidden reasoning
appears only as bounded metadata such as duration and token usage. From it a
developer can prove which turn runs, what input produced it, which worker owns
it, which tools ran, why it stopped and whether all required evidence is
durable. Content needs a separate content grant and resolves through the harness's
reader, so it may be unavailable (D-168); the base
timeline always shows lifecycle and correlation evidence.
Health state
| State | Meaning |
|---|---|
healthy | mutations, workers, evidence, storage and required adapters operate |
degraded | reads work; a bounded subsystem lags or is unavailable |
blocked | consequential admission is paused to preserve authority or evidence |
recovering | startup, restore, migration or reconciliation is in progress |
Each failing check returns a stable diagnostic code, severity, affected scope,
first and last observed time, evidence IDs, operator impact and exact
remediation. No composite healthy: true may hide a failing required check.
Metrics and SLOs
Today the Node emits maia_node_* metric snapshots in its diagnostics stream
(telemetry). The target set below is required for
release. Labels are limited to component, operation family, outcome, state,
provider adapter and deployment profile; Project, Session and user IDs are
trace fields, never labels.
maia_operation_duration_ms maia_budget_reserved_micros
maia_operation_total maia_budget_settled_micros
maia_event_commit_duration_ms maia_provider_attempt_total
maia_trace_outbox_lag_records maia_provider_rate_limited_total
maia_trace_projection_failures_total maia_assignment_lease_age_ms
maia_session_state_total maia_observer_dropped_records_total
maia_activation_queue_wait_ms maia_observation_ingress_batch_records
maia_activation_duration_ms maia_observation_ingress_queue_used_ratio
maia_worker_heartbeat_age_ms maia_observation_coalesced_records_total
maia_effect_outcome_unknown_total maia_observation_gap_records_total
maia_message_delivery_age_ms maia_observation_processor_lag_records
maia_routine_tick_lag_ms maia_observation_broker_subscriptions
maia_storage_used_ratio maia_observation_broker_disconnect_total
Release gates for the deterministic control plane, excluding live provider latency:
- reads: p95 below 100 ms, p99 below 250 ms;
- mutation commits: p95 below 200 ms, p99 below 500 ms;
- local activation admission: p95 below 500 ms;
- event visible to a live observer: p95 below 500 ms;
- fan-out adds no synchronous worker wait beyond the durable commit;
- diagnostic emission: p99 below 2 ms before queue acceptance;
- trace-outbox lag: below 5 seconds in steady state;
- required Swarm processor lag: below 1 second steady, 10 seconds at the qualified burst;
- a stale worker fenced within 20 seconds;
- zero duplicate consequential effects in all fault tests;
- zero missing required events or receipts in the acceptance corpus.
Benchmarks record machine profile, revision, registry digest, dataset, warm-up, workload, sample count, percentile method, result JSON and a 10% regression budget. Qualification fails if subscriber count changes worker emission or slow consumers raise worker or provider latency beyond that budget. Live providers are reported separately.
Retention, export, and privacy
Decided defaults, not yet enforced (decision; telemetry retention is in local telemetry):
| Data | Default |
|---|---|
| command receipts | 90 days |
| events | 14 days or 64 MiB (D-188) |
| redacted Session traces | 30 days |
| operational debug logs | 7 days |
| evaluations | 90 days |
| generated artifacts | 30 days |
| redacted opt-in voice transcript | 7 days |
| raw audio | never persisted |
| exported bundles | 7 days or explicit shorter TTL |
- Project policy may shorten retention; lengthening needs user-level policy and a preview.
- Purge scans references, previews exact classes, counts and bytes, commits tombstones and a receipt, deletes keys or blobs, and verifies absence. A legal hold is an explicit user-level grant shown in every purge preview.
- Exports are encrypted, bound to actor and destination, classified and expiring; access, download, revoke and purge are audited. Maia cannot prove deletion of a file copied elsewhere and says so in the receipt.
AI dependency outage and manual recovery
Planned (R10). Maia, Workspace and Project coordinators share one readiness policy: LLM plus Jev for automated decisions, LLM-only execution workers with central setup. A missing, disabled or unhealthy required dependency is explicit unavailability, never permission to switch to another LLM, bypass with a cached score or fall back to autonomous FIFO.
- Record the affected capability and the exact pending requests. Stop new affected AI decisions and dispatches; keep bounded blocked state and expiry. A provider timeout is not proof of failed or cancelled work.
- Keep authoritative observations, receipts, finite lease handling, acknowledgements, cancellation and unknown-effect reconciliation running. Admitted work continues within its frozen grants; a step that needs new AI preparation waits. Losing Jev never kills an unrelated running worker.
- Keep authenticated manual CLI, API and SDK reads and explicit controls working without evaluator credentials, with deterministic status and confirmation views that explain the outage. Manually configured LLM work still needs its own LLM, grants and budget; it is not a hidden fallback.
- Restore the dependency, check health and reconcile the original invocation and operation receipts before resuming. Revalidate owner, revisions, consent, expiry, context and budget. Never replay uncertain spend or effects under new identities.
- Rebuild queues and context from authorized journal checkpoints where needed. Drop stale semantic selections; keep required outcomes and delivery identities. Deliver deferred feedback after scope checks, not as a burst to every device.
Qualify provider-down, malformed or late output, restart and recovery with deterministic Effect layers and clocks before approved live trials, and test manual controls with zero evaluator calls.
Doctor, alerts, and incident response
Planned. Diagnostic codes are registry-owned. Categories: configuration, identity, authorization, storage, evidence, worker, scheduler, provider, placement, security, retention and compatibility. Each code defines severity, blocking behavior, deterministic check, remediation and whether auto-remedy is allowed. The Maintenance Agent may propose a change but never apply its own proposal; automatic application is limited to registry-classified reversible changes that do not touch authority, providers, network, retention, tool grants or code (maintenance proposals).
Release tests exercise these runbooks:
- regain local admin access and revoke a device, Node, Connection or peer;
- recover an orphaned worker or an
outcome-unknowneffect; - handle disk pressure, SQLite corruption or a JSONL chain failure;
- rotate a compromised provider or Node credential;
- hold or drain a Project or Node and complete a handoff;
- back up, restore, migrate and roll back safely;
- isolate hostile A2A, MCP or artifact ingress;
- preserve evidence and produce a redacted incident export.
Reporting and disclosure follow the security policy.
Test and release evidence
Every slice has unit, schema and contract, repository integration, SDK and CLI, fault, security and end-to-end tests at the lowest useful boundary. Deterministic layers provide clock, IDs, filesystem, SQLite, worker process, provider, secret store, transport and scheduler.
Mandatory fault points: every commit, insert, outbox, segment and projection step; every worker lifecycle edge; provider timeout, 429, token expiry, partial stream, callback loss, duplicates and unknown outcome; diagnostic saturation, gaps, processor crashes, checkpoint rewind, slow subscribers, cursor reset, policy revoke and broker restart; Routine catch-up, reservation rollback, lease expiry, Node loss, split-brain and handoff; OAuth replay, stolen devices, cross-Project access, path races, inherited secrets, SSRF, artifact traversal, prompt injection and voice confirmation replay; backup, restore, key loss and every migration phase.
A release evidence bundle holds the revision, packed artifact hashes, SBOM and notices, registry digest, schema and migration versions, clean-host transcript, test summaries, fault matrix, benchmark JSON, backup and restore proof, credential scan and owner sign-off. An exception needs a named authority, expiry, affected gate, mitigation and public diagnostic; silent waivers are forbidden. Channels, packaging and updates are in release.
Imported from Maia OS docs at 3dc3e07