Skip to content

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.

ProfileTransportWorkersRelease scope
local foregroundauthenticated loopback HTTP/WebSocketlocal onlysource candidate today
local serviceloopback API; managed device credentiallocal onlyv1
tailnet controlmTLS over tailnetlocal and remote Nodesv1 candidate after MN1
public internetnonenonenon-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):

DatamacOSLinuxWindows
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 0700 and files 0600, 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 (default profile when omitted). A Desktop-owned child sets MAIA_DESKTOP_PARENT_PIPE=1 and 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.
  • runtimeRootOverride co-locates the three profile locations in one directory; it does not migrate existing state. MAIA_AGENT_HOME moves only the agent directory. RL3 moves everything into one ~/.maia home (release).
  • Known gap: runtime profiles and configuration resolution are not joined at startup. runDesktopNode takes profile and runtimeRootOverride but 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.service over SSH answers active before 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, and MAIA_HOME keeps the release tree, login and agent files.
  • Another profile. Change --profile in ExecStart and name the unit maia-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:

  1. Stops with exit 0 while updates are paused (remote pause).
  2. Reads the exact version the canary npm dist-tag names from the registry. canary is the only channel the config accepts, because Maia ships canary builds only (D-202); the updater never follows latest or any other tag.
  3. 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 same remote install as above: drain, swap, ready check, Session check and automatic rollback.
  4. Stops at the first Node that waits, is busy (busy exit 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/cgroup and 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/agent managed-worker package under node_modules/ (the Node checks it against its embedded identity at start), the maia CLI bundle maia.mjs with bin/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), and SHA256SUMS; the runtime is runtime/node-<sha12>/bin/node; current is 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 whose WorkingDirectory= names another root stops the plan and every script), the bridge launcher profiles/<profile>/bridge, and install.json lists what Maia installed. Maia reads install.json by 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 with manifest_invalid, and rollback and uninstall with not_installed, and all four name one recovery: restore it from a backup you have checked, or install into another --root with 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 with too_many_releases.
  • Install takes <root>/.install-lock under a nonce that every later step checks, and only while install.json is 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, profiles or run directory 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's SHA256SUMS and runs node maia-node.mjs --json version and, 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 script launch inside 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/launch and 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 of install.json takes the link as the active release. Before any activation Maia refuses a ledger that does not list the active or the previous release, or a current link 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-pending in 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 answers node.status over the bridge is the unit's MainPID, 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 the uninstall (or rollback) 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 codex and claude (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's 775 default): named after the user, its primary group, with no other member in /etc/group and no other /etc/passwd entry 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 with integration 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's bin/ (its maia launcher 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. launch is 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 sets Delegate=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 after TimeoutStopSec; a managed unit installed earlier picks up KillMode=mixed on its next install or update), Restart=on-failure and UnsetEnvironment=NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS MAIA_NODE_TEST_MODE MAIA_NODE_TEST_RUNTIME_ROOT (launch and the version check drop them too). launch exports MAIA_HOME=<root>, XDG_STATE_HOME=<root>/profiles/<profile>/state and XDG_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 on 127.0.0.1 alone. 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 ~/.maia is 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 and current never 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, then daemon-reload), then the final rename switches to the new release. sync runs after the entry-point renames and around the final rename, so the pointer never outlives the files it names. The current link decides the active release. install.json is 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 current back to the previous release, re-verified, with that release's bin/ 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.json lists; 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. --purge removes 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 = 1000 ms, 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): synchronous becomes configurable as events.fsync (always = FULL, the default); storage hardcodes FULL today;
  • quick_check and foreign_key_check at every open; failure is storage_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 with storage_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 to 0600 only 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, diverged or unreadable. 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 gives unreadable. The first read of a closed WAL database creates its -shm and an empty -wal; SQLite keeps both, and later reads add nothing. An empty main file next to a -wal is unreadable without being opened, because SQLite would delete that log. It runs no integrity or ownership check, so readable does not promise a clean start (Source candidate);
  • maia-node release use gates a switch on this check: it runs the target build's state check and switches only when the verdict is absent or readable and the build declares startGuard: 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_failed instead of a stale backup), copy to migration-backups/, verify the copy, migrate, verify again. A migration that drops content leaves a storage_scrub_pending marker 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 of state/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_check runs 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.

  1. 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>.json record exists.
  2. Wait 2 minutes. adopt refuses a session active in the last 2 minutes.
  3. 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, then maia connection grant set --confirm-stdin < grant.json.
    • Approve the vendor executable: maia integration plan <vendor>, then maia 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.
  4. Preview and confirm. maia --json session adopt <claude|codex>:<native-id> --workspace <workspace-id> --dry-run > preview.json (add --attest-ended for Codex, which keeps no liveness record), check the directory and Connection in the preview, then maia --json session adopt --confirm-stdin < preview.json within 15 minutes. The Session starts stopped. For many sessions, list them in a file and run maia --json session import --from sessions.json --dry-run > import.json, then maia --json session import --confirm-stdin < import.json; a rerun adopts nothing more. Detect the installed harnesses with maia agents detect.
  5. 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 compare maia session history read <session-id> with the old transcript. The history shows the pre-adoption messages.

Limits:

  • import scan lists codex exec threads. A Codex thread shows observed: running when a Codex process holds its rollout file open or was started with resume <thread-id> or exec resume <thread-id>; any other thread is unknown, so an empty --running result 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 login on the Node host, as the same user and config root). A lane that runs through a gateway or settings endpoint fails the resumed Session with authentication_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 failed if 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-required until a fresh enrollment, reconciles external effects and switches the endpoint only after the operator applies the plan. It never overwrites the source.
FailureRPORTO
worker or process crashzero committed5 minutes
Control Node process crashzero committed10 minutes
Control Node disk loss24 hours60 minutes
one Execution Node losszero committed10 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).

  1. OS and architecture (what Maia packages, per release): darwin-arm64 and linux-x64 are the platforms the release document lists. win32-x64 appears in D-019 and the native process-host build scripts, but no release lists it, so it is unsupported until one does.
  2. Containment tier (D-019, source candidate): linux-x64 has a hard tier (cgroup v2, Landlock, seccomp; Linux 6.12+ and a delegated cgroup v2 parent). darwin-arm64 has 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.
  3. 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.
  4. 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:

  1. capability.revoke rejects new matching operations at once;
  2. Node fences matching queued and running assignments at the next safe edge;
  3. in-flight external effects are interrupted when supported, otherwise they enter outcome-unknown reconciliation;
  4. streams receive a terminal policy-revision event;
  5. 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 or all. 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 with held. 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 in starting, running or recovering on the targets (at most 64), and the confirmation binds that list. In the hold's transaction each Session still running stops: queued turns fail turn_rejected, an in-flight turn settles outcome-unknown (runtime_exited) for the existing reconciliation, the activation stops, and the Session records session.stopped with the holdId. After the commit the Node ends every stopped runtime (Codex, Claude Code and the managed Pi worker), as session.stop does. When a runtime has not ended within its 3-second teardown deadline, the confirmation answers retryable saturated (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 a keep hold 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 timestamped hold.placed or hold.released event; a stop adds one session.stopped event 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.run and 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. A stop hold does not settle terminal-backed Run dispatches; settle those with run 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.preview and confirm, pause.release.preview and confirm, and pause.list, mirroring holds; CLI first as maia pause <kind> <id>... [--deadline <duration>] [--until <time>], maia resume <pause-id> and maia 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, or outcome-unknown for reconciliation). Pending questions and approvals stay pending, and answering one starts no turn until resume. The Node then stops each Session's harness process as session.stop does, 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 as deferred and queues event ticks up to its pending-tick cap; overflow is blocked, 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 --until time chosen at pause, with its own receipt. Each Session resumes as session.resume does, on its native session, re-acquires its leases (a lease another owner took fails that Session's resume with lease_conflict, reported, never forced) and runs queued turns in order. A Session with an unresolved outcome-unknown effect 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.placed or pause.released event, plus session.paused or session.resumed per 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.

LaneContentDelivery rule
authoritativecommands, revisions, receipts, effects, terminal messages, final usagecommitted with the operation, never sampled; a failure pauses admission at the next safe edge
diagnostictext deltas, progress, debug logs, span details, metric snapshotsnon-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 ownerSequence for causal truth and also get a logical (partitionId, partitionSequence): 32 partitions by a versioned stable hash of nodeId + projectId-or-ownerId. Cross-partition order is never inferred.
  • StreamBroker subscribes 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

StateMeaning
healthymutations, workers, evidence, storage and required adapters operate
degradedreads work; a bounded subsystem lags or is unavailable
blockedconsequential admission is paused to preserve authority or evidence
recoveringstartup, 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):

DataDefault
command receipts90 days
events14 days or 64 MiB (D-188)
redacted Session traces30 days
operational debug logs7 days
evaluations90 days
generated artifacts30 days
redacted opt-in voice transcript7 days
raw audionever persisted
exported bundles7 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. regain local admin access and revoke a device, Node, Connection or peer;
  2. recover an orphaned worker or an outcome-unknown effect;
  3. handle disk pressure, SQLite corruption or a JSONL chain failure;
  4. rotate a compromised provider or Node credential;
  5. hold or drain a Project or Node and complete a handoff;
  6. back up, restore, migrate and roll back safely;
  7. isolate hostile A2A, MCP or artifact ingress;
  8. 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