Decision log
Every decision worth keeping lives here: the choice, why, the alternatives rejected, the date and links. Other documents state the resulting contract and link the entry; they do not repeat the reasoning. To change a decision, add a new entry and mark the old one superseded, or amended when the new entry changes only part of it.
Each entry is ## D-NNN Title (YYYY-MM-DD) — status: accepted | pending owner | superseded by D-x, followed by the decision, why, rejected alternatives,
consequences and links. An accepted entry that a later entry changes in part
reads accepted, amended by D-x. The date is the commit or document that recorded the
decision. "Planned" names the roadmap slice that implements it.
Areas: direction and architecture (D-001–D-013); workers, containment and accounts (D-014–D-025, D-075, D-084, D-090, D-097, D-098, D-101, D-116, D-118, D-122, D-123, D-145, D-150, D-167, D-174, D-180, D-184, D-198, D-199); orchestration and CLI (D-026–D-034, D-070, D-073, D-074, D-077, D-078, D-081, D-082, D-083, D-107, D-119, D-120, D-136, D-151, D-152, D-162, D-163, D-166, D-171, D-172, D-173, D-178, D-201); agent, context and retention (D-035–D-038, D-069, D-128, D-168, D-169, D-188, D-195); configuration, Skills and extensions (D-039–D-041, D-087, D-105, D-114, D-121, D-124, D-127, D-170, D-190); home, release, compatibility and hosting (D-042–D-044, D-072, D-080, D-085, D-111, D-112, D-115, D-134, D-141, D-176, D-196, D-202, D-205); telemetry and voice data (D-045–D-048); clients (D-049–D-056, D-076, D-079, D-089, D-109, D-161, D-175, D-177, D-183); packaging and code quality (D-057–D-059, D-061–D-068, D-102, D-113, D-117, D-125, D-126, D-130, D-131, D-133, D-135, D-154, D-160, D-179, D-181, D-182, D-189, D-191, D-192, D-194, D-200); execution authorizations (D-060, D-129).
D-001 Maia is the coordination hub for any agent and harness (2026-10-01) — status: accepted
- Decision: an organization runs a Maia Node that people's agents (Claude Code, Codex, Pi, external A2A agents) connect to. They delegate to each other through it with Node-stamped identity, grants, human approvals and receipts.
- Why: owner direction.
- Consequences: v1 stays focused on one user's swarms, and current slices keep this path open: a generic principal model (human, Session, external peer) in S18, principal- and grant-based messaging in S17, the Run ledger and receipts as the audit trail, A2A ingress after S17, and no network exposure before remote authentication (MN0). Not built yet: person identity (SSO/OIDC), authenticated remote access, isolation between people on a shared Node (separate OS users or containers; today's containment bounds the process tree, not the user), a cross-person delegation and consent policy, agent discovery, per-person quotas and federation between organizations.
- Links: roadmap, team Workspaces, Node identity and tenancy.
D-002 One Control Node is the only durable writer (2026-09-02) — status: accepted
- Decision: one Control Node owns durable truth. It hosts the Node and Swarm Effect services in one process with local SQLite. Execution Nodes and workers are disposable, fenced capacity with no competing Project or Session truth.
- Why: one authority with identical semantics on every Node; the register records no further reason.
- Rejected: SQLite replication, leader election, distributed transactions, a separate Swarm database, replicated control-plane state in workers, Control high availability or automatic failover in v1.
- Consequences: a Control outage blocks new admissions and renewals; workers obey finite leases. Revisit only when measured capacity or a second deployment consumer justifies extracting Swarm services.
- Links: Node, operations topology.
D-003 Local SQLite plus projected evidence (2026-09-02) — status: accepted, amended by D-188
- Decision: SQLite on a local disk (WAL,
synchronous=FULL, one writer) is the durable store. JSONL is immutable evidence projected from it; artifacts are encrypted files. Backups follow the RPO/RTO table (daily, kept 7 daily and 4 weekly). - Why: a single-writer Control Node needs no hosted database.
- Rejected: network or synced filesystems, multiple writers, replication, a hosted database.
- Consequences: SQLite is a source candidate; JSONL evidence and backup commands are not implemented and have no roadmap slice. Revisit when qualified high-availability needs exceed the backup objectives.
- Links: SQLite contract, backup and recovery.
D-004 One Maia Agent kernel over the official Pi SDK (2026-09-02) — status: accepted
- Decision:
maia agentis Pi rebranded on the official@earendil-works/pi-*packages. One kernel serves local CLI use, the managed worker and future coordinators; each keeps its own owner. Pi extensions are the only customization boundary; Maia owns paths, admission, credential references, safety and receipts. Maia Swarm is not another harness, and a child agent is a normal Session with a parent relation. - Why: reuse the official loop, TUI and extensions; keep one implementation; keep authority in the Node.
- Rejected: a separate Swarm harness or second model loop, a forked Pi,
ambient
~/.pistate, a Maia plugin or compatibility API, and public Fleet, Workflow, Task-graph, Bot or Subagent runtimes or CLI roots (Session-localtaskis a planning tool only). - Consequences: Pi is not a sandbox; containment comes from execution profiles. Extension code runs with the process's OS permissions.
- Links: Agent, ARCHITECTURE.
D-005 Separate coordinator owners with parent-mediated delegation (2026-09-29) — status: accepted
- Decision: Maia, Workspace and Project coordinators are distinct principals with isolated history, memory, grants, credentials and resources. Maia sees its authorized Workspaces; a Workspace sees only its Projects; a Project cannot discover siblings. Cross-scope work goes through the parent.
- Why: shared code must not become a shared brain or a lateral authority path; human portfolio access is never inherited by an agent.
- Rejected: direct cross-Project bridges (
BridgeGrantV1, the formerswarm bridgecommand), a reserved operator Project, implicit grants. - Consequences: owner schemas and preserving migrations must exist before coordinators activate (R3). Separate processes alone do not prove isolation. The former "Administrator Session" is now the Maia coordinator.
- Links: ownership and visibility, Node enforcement.
D-006 A2A v1 at the external boundary; MCP for tools and context (2026-09-02) — status: accepted
- Decision: external agents interoperate through A2A v1 (discovery, messages, tasks, artifacts, task updates); Maia operations stay the internal contract. Each accepted A2A task maps to one durable Maia task. MCP supplies tools and context only.
- Why: a standard boundary for peers without granting them internal leases or mutation authority.
- Rejected: a custom peer protocol; MCP as Workspace, Project or task truth.
- Consequences: A2A ingress comes after S17 messaging (D-001). Revisit only for a newer A2A version with an explicit compatibility migration.
- Links: Swarm A2A, AGENTS rules.
D-007 Loopback by default; authenticated tailnet; no public ingress (2026-09-02) — status: accepted
- Decision: the Node binds to loopback. Remote access, when built, uses authenticated mTLS over a tailnet; Tailscale Funnel and public ingress stay off.
- Why: reachability is not authorization.
- Rejected: LAN or public listeners without an approved threat model.
- Consequences: remote listeners need pairing, authentication, revocation, TLS, recovery and negative tests first (MN0). Revisit only with an approved public threat model and ingress slice.
- Links: operations topology, mobile pairing proposal.
D-008 Node-owned observation plane with mandatory lifecycle evidence (2026-09-02) — status: accepted
- Decision: every authoritative command, transition, activation, effect, message and recovery decision has durable correlation evidence and a live Node projection. The Node batches, partitions, checkpoints and fans out authorized streams after commit; clients and processors never subscribe to workers or provider sockets directly.
- Why: no unobservable authoritative transition; slow observers must not slow control.
- Rejected: Kafka, Redis, NATS or another broker or database in v1; sampling lifecycle edges.
- Consequences: a missing required edge blocks consequential admission. Revisit when measured load justifies a contract-compatible extracted broker.
- Links: live observability.
D-009 One root AGENTS.md and progressive Skills (2026-09-02) — status: accepted
- Decision: only the root
AGENTS.mdloads at Session start; nested files are off by default and, when a Project enables them, only the nearest file loads. Skill bodies load by exact eligible ID after a metadata index, within byte budgets, with a context manifest per activation. Repository and user text is untrusted instruction. - Why: context is scarce; startup stays cheap and auditable; text never becomes authority.
- Rejected: a
rules/tree, Markdown globs, Skill preloading,APPEND_SYSTEM.md/SYSTEM.md, global extension prompt appends, home directory discovery. - Consequences: today the agent loads every
AGENTS.mdfrom the root to the working directory; closing that gap is R1/R11 work. - Links: minimal-context contract.
D-010 Managed secrets go to an OS secret store (2026-09-02) — status: superseded by D-122
- Decision (2026-09-02): one
SecretStoreEffect service with macOS Keychain as the only managed v1 backend; locked or unavailable storage is a typed blocking state; no plaintext or environment fallback in managed mode; workers get short-lived single-activation credentials. - Why: OS-protected custody keeps secrets out of files, environment and arguments.
- Rejected: plaintext and environment fallbacks.
- Consequences, as built: no
SecretStoreor Keychain backend exists. Model-provider credentials live in~/.maia/auth.json(owner-only, read only as a 0600 single-link file); worker accounts stay in vendor custody (D-020). On macOS the Pi RPC path writes a per-Sessionauth.jsonwith the leased access and refresh token that nothing removes. The owner must amend or supersede this entry; a proposed amendment is "local v1: owner-onlyauth.jsonplus vendor custody; a managed secret store (Keychain first, a separate Linux backend) before MN1 and the master VPS". - Links: identity, enrollment and secrets, providers secrets, security limits.
D-011 Workspace means organization; Execution Environment means execution (2026-09-29) — status: accepted
- Decision: organizational
container.*/containerIdbecomeworkspace.*/workspaceId; executionworkspace.*becomesenvironment.*/environmentId. Execution Node is the host and Execution Profile its permissions. The migration is versioned and ID-preserving. - Why: "Workspace" meant two things on the wire.
- Rejected: Container, Execution Workspace or ProjectEnvironment as product
terms; an immediate blanket rename; silent reuse of
workspace.*; prefix rewriting; guessing meaning from display names. - Consequences: R1b; old proofs and receipts reconcile under their original contract; mixed-version tests.
- Links: naming contract.
D-012 Workspaces start as a separate container aggregate (2026-09-17) — status: accepted
- Decision: organizational Workspaces are an additive
container_*aggregate (maia.container/v1); executionworkspace_*keeps its meaning. Migration 35 gives each Project one container named after it, andproject.registercreates its container in the same transaction. - Why:
workspace_*IDs are referenced by Sessions, receipts and events. - Rejected: renaming or rewriting IDs in place, merging Projects, table rebuilds or disabling foreign keys.
- Consequences: two namespaces until R1b (D-011); triggers plus a guard table are the migration pattern. Source candidate.
- Links: Workspace hierarchy delivery.
D-013 First-owner authority comes from the OS profile owner (2026-09-14) — status: accepted
- Decision: the local Node API bearer authenticates transport only. First-owner authority comes from the OS account that owns the profile's runtime directory, credential and descriptor, plus a one-shot confirmation from the controlling terminal. The Node binds the principal; requests cannot claim an identity, role or Node.
- Why: holding the bearer must never be enough to create the first owner.
- Rejected: bearer-only HTTP bootstrap, a named FIFO or raw file reads for confirmation, Keychain, biometric, managed-device or remote identity claims.
- Consequences: the trust boundary is the OS account; a managed credential
must replace this local mapping before remote or extra-device access. The
prototype was deleted on 2026-10-01 (
9cdbe82); the design stays. - Links: team Workspaces.
D-014 v1 workers are vendor executors (2026-09-29) — status: accepted
- Decision: v1 workers are Codex CLI through
codex app-serverand Claude Code through the Agent SDK driving the user's installedclaude, each bound to a named account. The brain is Claude Code or Codex using themaiaCLI or MCP plus a Skill. - Why: user priority: drive many workers from the harness the user already
pays for. Spike evidence (darwin-arm64, codex-cli 0.153.4, Claude Code
2.1.284, Agent SDK 0.3.284): app-server initialize, account, rate-limit,
thread and turn calls worked over stdio; Codex limits arrive as a typed
usageLimitExceededwith a reset time; the Agent SDK ran on the subscription plan without overage;spawnClaudeCodeProcesshands the executable, arguments and environment to Maia. Not yet observed: concurrent Codex processes on one account across a token refresh. - Rejected: Maia Agent (Pi) as the v1 worker backend; it stays native work for later slices.
- Consequences: the user accepts the terms risk of subscription use per
account; Anthropic permits subscription use through the unmodified
claudebinary signed in by the user. Maia never reads or proxies executor credentials. Dogfood on 2026-09-30: an MCP-only Claude Code brain ran a two-worker fan-out in 15 tool calls without errors. - Links: vendor executors, roadmap v1.
D-015 Containment is the vendor sandbox Maia configures; read-only first (2026-09-29) — status: accepted, amended by D-198
- Decision: Codex runs a Maia permission profile (
maia-read: root deny, minimal read, worktree read, network off) passed only as-coverrides. Claude runs with a fixed PreToolUse path hook (Read, Grep, Glob inside the worktree only). - Why: the spike showed Codex's built-in
:read-onlyprofile allows reads everywhere, including other repositories, a sibling account root and~/.codex/auth.json; the custom profile denied them. The Claude hook also sees auto-allowed reads, unlikecanUseTool. - Rejected: the built-in
:read-onlyprofile;canUseToolalone. - Consequences:
/tmpstays reachable, so each Session gets its own temporary directory. Coding workers (write profile, Claude Bash sandbox) are a separately qualified slice (S5). - Links: containment policy.
D-016 Claude Code runs through the Agent SDK with a Node-owned process (2026-09-29) — status: accepted
- Decision: use
@anthropic-ai/claude-agent-sdkwithpathToClaudeCodeExecutable, a Node-ownedspawnClaudeCodeProcess,settingSources: [],strictMcpConfig, neverbypassPermissionsorsessionStore. - Why: the
claudeprocess stays under Maia's process manager, and no option copies the OAuth token. - Rejected: letting the SDK spawn its own process; loading user or project settings.
- Consequences: one dependency under Anthropic's commercial terms, pinned to an exact version and exempt from the release-age wait.
- Links: Claude Code executor.
D-017 Codex workers start with MCP servers and outside-profile features off (2026-09-29) — status: accepted
- Decision: before launch Maia lists the account's configured MCP servers
and disables each by name, disables the Codex features that run outside the
permission profile, reach the network or read other projects' memory, sets
web_search="disabled", and ends a Session that still reports an MCP server starting. - Why: dogfooding showed
-c mcp_servers={}merges into the user's MCP table instead of replacing it, so user servers (a JavaScript REPL, a deployment tool, computer use) started inside the read-only worker outside the profile; hosted search bypasses the local network denial. - Rejected: trusting
-c mcp_servers={}. - Links: containment policy.
D-018 The process manager selects the containment tier (2026-09-01) — status: accepted
- Decision: every launched process is registered with one resource
manager that selects its tier from one platform table; the caller never
chooses. Hard containment applies where the platform proves it. macOS
exceptions are cooperative tiers for exact vouched official binaries
(
cooperative_multi_pgidfor Codex and Claude Code,vouched_runtimefor Git and, for thesecret-storeowner alone,/usr/bin/security(D-139)), each stating its assumptions and lack of survivor guarantee. Managed execution stays read-only, and unsandboxed tools run only in explicit local foreground mode, until a qualified OS containment adapter passes escape and fault qualification (architecture register, 2026-09-02). - Why: Maia must not claim containment it cannot prove.
- Rejected: caller-selected tiers, numeric PID/PGID signalling fallback, treating missing measurements as zero or as an allow.
- Consequences: missing evidence fails closed. The rules were adopted from an earlier "ADR-009" whose text is not in this repository; this entry replaces it. The code also has tiers for Pi RPC, interactive terminals and execution commands on macOS that the rule does not list (owner to confirm).
- Links: Node rules.
D-019 Linux workers run under a cgroup v2 host with Landlock and seccomp (2026-10-01) — status: accepted
- Decision: a native Linux process host gives each worker tree its own
cgroup v2 directory in the Node's delegated subtree (
clone3withCLONE_INTO_CGROUP); the kernel enforces memory and a task cap, the helper enforces process count, CPU and wall time,cgroup.killstops the tree and an empty removed cgroup is the cleanup proof. Before exec each target getsno_new_privs, a Landlock domain (no writes under/sys, no signals or abstract sockets outside the tree) and a seccomp filter (clone3answersENOSYS;prlimit64and scheduling calls act on the caller only). Hard containment is granted only to platform rows marked hard (linux-x64, win32-x64; darwin is cooperative). - Why: VPS workers need enforced limits and proven cleanup. An independent
review found escapes (
clone3into another cgroup,prlimiton the helper, abstract sockets,chmodof the kill switch); the seccomp filter and held descriptors closed them. - Rejected: running workers on Linux without a hard tier.
- Consequences: needs Linux 6.12+ for Landlock signal scoping and a delegated cgroup v2 parent. It bounds the process tree, not the user: a hostile worker can still read the user's files and reach user services. Full isolation needs a per-executor filesystem allowlist and a separate account (not in v1). Source candidate.
- Links: Linux process host; Codex trees amended by D-110, coding Claude Code trees by D-184.
D-020 One maia login for worker accounts and model providers (2026-10-01) — status: accepted
- Decision:
maia loginis the one sign-in. It lists and adds worker accounts (Codex, Claude Code; Node-owned Connections whose login stays in the vendor's own config root) and signs the Maia agent in to Pi model providers (one credential per provider in~/.maia/auth.json). - Why: one entry point for people and agents.
- Rejected: separate
providers,cursor,codex-poolorgatewaycommand trees;connection addand the agent-only key login it replaced. - Consequences: Pi is the only writer of
auth.json; keys are never echoed, a key on stdin never replaces a subscription.maia agent loginandconnection login/signinremain secondary paths. - Links: providers, CLI reference.
D-021 The Node picks the least-used account (2026-10-01) — status: accepted
- Decision: when a caller names only the executor kind, the Node skips accounts that are disabled, signed out, limited, used up (a live 5-hour or weekly window at 100%) or full, then picks the lowest live-window usage, then the most free slots, then the name. Unstarted Sessions from the last 10 minutes hold their slot. The choice is recorded and replayed on retry.
- Why: spread load deterministically across accounts.
- Rejected: first account found; the earlier "fewest active Sessions, then lowest ID" rule (D-022); polling private usage endpoints.
- Links: account selection; amended by D-097.
D-022 Connection pools per Workspace or Project (2026-09-29) — status: accepted
- Decision: a
connectionPoolbelongs to one Workspace or Project, insingleorrotatemode. A Project pool overrides its Workspace pool; no default crosses Workspaces. Rotation is explicit, recorded, opt-in and happens only at Session admission; later turns stay on the Session's account. - Why: account choice must follow scope grants.
- Rejected: global defaults; switching accounts mid-Session.
- Consequences: S2 implements it (D-147). Its original rotate
order ("fewest active Sessions, then lowest ID") is replaced by D-021's
order;
rotatereuses D-021 on pool members, which the owner should confirm. - Links: Connection pools.
D-023 Deterministic provider selection without silent fallback (2026-09-02) — status: accepted
- Decision: model and provider resolution follows a fixed order; ambiguity fails. Fallback is off by default and allowed only for verified pre-dispatch or no-output failures, never after any token, tool call, effect or unknown outcome. Retries use full-jitter backoff from 1 s, capped at 30 s, at most 3 attempts.
- Why: a silent switch can duplicate effects or change behavior unseen.
- Rejected: first-found account, silent fallback, failover after output.
- Consequences: planned with R10.
- Links: selection and fallback.
D-024 Cursor Cloud as a hosted executor (2026-09-02) — status: pending owner
- Decision (2026-09-02): Cursor Cloud would be a complete external Agent Session executor through its SDK, never a model provider.
- Open: v1 workers are Codex and Claude Code (D-014), and the roadmap no longer plans a Cursor executor, so its design was removed from the docs. The rule "a hosted coding agent is an executor, never a model provider" stays in the providers contract. The owner should confirm the drop (then mark this entry superseded by D-014).
- Links: providers.
D-025 Model providers as Connection records (2026-09-02) — status: superseded by D-014 and D-020
- Decision (2026-09-02): Codex accounts and the Vercel AI Gateway would be
openai-codexand Gateway Connection records with a Maia login ceremony, and no AI SDK would be added only for the Gateway. - Why superseded: Codex and Claude accounts became vendor executor
Connections (D-014); model-provider credentials are Pi provider credentials
behind one
maia login(D-020); the AI SDK was added for Realtime voice. - Links: providers.
D-026 Deterministic primitives in Maia; orchestration in Skills (2026-09-30) — status: accepted
- Decision: three layers, in order. (1) The Node, protocol, SDK, CLI, MCP and API expose deterministic, non-agentic operations (create, start, send, wait, observe, interrupt, stop, resume) with revisions, receipts and bounded monitoring. (2) Orchestration (fan-out, join, supervise, retry, review) lives in Skills that a brain runs. (3) An optional decision model may split, classify, route and score over supplied options; it proposes, the Node validates and grants nothing.
- Why: agents ignore rules written only in prompts, so safety rules belong in the Node.
- Rejected: a hidden planner, agent loop or scheduler inside Maia; LLM voting or consensus.
- Consequences: every control works without the decision layer (S8).
- Links: coordination, roadmap v1.
D-027 Advisory port and lock leases; no kernel enforcement (2026-10-01) — status: accepted
- Decision: one advisory lease table with kinds
port(Node-allocated, stable preferred port, connect and bind probes) andlock(exact keys). Leases end with the owner Session's cleanup, at TTL expiry or when the Node epoch changes. - Why: agents on one machine collide on ports, databases, devices and merges; enforcement breaks real tools.
- Rejected: kernel bind restrictions or Landlock enforcement and mandatory
leases (they block harnesses, Docker and fixed OAuth callback ports);
port killor killing by name (how a sibling's server dies); glob reservations and pre-commit guards (overlap and sweep bugs); device, database, Redis or Compose primitives (MAIA_WORKSPACE_SLUGpluslockcover them); build-cache locks (tools already lock); a.localhostproxy (stable ports give stable URLs). - Consequences: S16. The
portkind shipped as a source candidate on 2026-10-01: 15-minute renewable leases fromMAIA_PORT_RANGE(default41000-41999), an explicit--preferredhint, andMAIA_PORTSinjected into Codex and Claude Code Sessions. Thelockkind, the other launch variables and a derived stable preferred port are wave 2. - Links: lease table.
D-028 The Run ledger is the orchestrator's durable memory (2026-10-01) — status: accepted
- Decision: the
run.*family drives executor Sessions as dispatches; a restarted orchestrator rebuilds its state from the ledger alone. A task that fails three dispatches stops being ready;session observegains anattentionvalue. - Why: a coordinator loses context or restarts; the ledger and receipts are also the audit trail (D-001).
- Rejected: a second task or claim engine, planner or scheduler.
- Consequences: S19.
- Links: Run ledger.
D-029 Emergency hold and kill switches on exact targets (2026-09-02) — status: accepted
- Decision: a hold or typed revocation targets one exact Project, Connection, peer, Node or capability. New work is rejected at once; running work is fenced at the next safe edge with typed outcomes; release needs a newer enablement. A machine-wide incident is a bounded set of exact targets with one receipt.
- Why: fast, exact stops without collateral damage.
- Rejected: process-global environment flags; an
alltarget. - Consequences: S20 (wave 1), reaffirmed with the swarm safety rules on 2026-10-01.
- Links: kill switches, safety.
D-030 No broadcast board; parent-mediated, Node-stamped messaging (2026-10-01) — status: accepted, amended by D-152
- Decision: agents coordinate only through the Node. Every message carries a Node-stamped sender; peer content is data, never authority or consent; messages are parent-mediated with durable per-inbox order.
- Why: in July 2026 about 1,200 evaluation agents improvised a message board on a shared internal service reached with one shared credential, and hundreds of them breached Hugging Face production (OpenAI technical report; METR and Hugging Face accounts).
- Rejected: a broadcast board, LLM voting or consensus, worker heartbeats, typing into terminals.
- Consequences: S17 after S18a.
- Links: safety, durable messages.
D-031 Worker roles, Session-bound credentials and Node-enforced guards (2026-10-01) — status: accepted
- Decision: orchestrator and worker roles get different Skills and plugins (orchestrator-only and worker-only items). Each worker reaches the Node only with a Session-bound credential; on Linux, Landlock denies workers reads of the Node runtime directory and the auth file. The Node enforces depth (1 by default), live children per parent, messages per minute and leases per Session.
- Why: without separate credentials, roles mean nothing.
- Rejected: shared credentials; a secret broker (authority without a current need).
- Consequences: S18a (wave 2), S18b (wave 3), S18c (wave 4); on macOS the read-denial limitation is stated.
- Links: safety.
D-032 Per-command --schema inspection (2026-09-29) — status: accepted
- Decision: registered resource commands accept
--schemaand--schema=input|output, offline and deterministic; schema mode never executes, reads stdin, resolves credentials or contacts the Node. - Why: users inspect the command they intend to use.
- Rejected: a separate
maia contractfamily for users. - Consequences: R1 (PF/DF-26).
- Links: schema-only inspection.
D-033 One JSON envelope and exit-code set for every surface (2026-09-02) — status: pending owner
- Decision (2026-09-02): one
maia.cli.v1envelope (operationId,requestId,ok,data,meta,receipt,error) and exit codes 0–8 across CLI, SDK, HTTP and tools; CLI families converge on one Agent Session vocabulary; no command is silently redefined;maia monitorbecomes an alias ofobserve health --watch. Its migration table mappedcontext showtocontext inspect|lint|explain,provider listtoconnection listplusmodel list, madeworkspacean internal Project projection andrun,turn, dispatch,executionand terminal leases internal records, and keptsession,receipt,project,status,monitorandversion; any migration needs a compatibility mapping, deprecation output and contract tests. - Why: the same operation data on every surface.
- Consequences, as built: the CLI writes
{ok, result}to stdout and{ok:false, error}to stderr with sysexits codes (64–77);maia monitorprints a loopback URL;turnandrun taskare public (D-026, D-028). The owner must decide whether the envelope stays an R11-only target or becomes a scheduled CLI migration. - Links: CLI reference.
D-034 Model shell tools take argv, never a shell string (2026-09-02) — status: accepted
- Decision: the model-facing shell tool receives an argument vector. Shell string semantics exist only through a separately reviewed privileged profile, absent in v1.
- Why: not recorded in the source document.
- Links: CLI reference.
D-035 Thin terminal UI over Session projections (2026-09-02) — status: accepted
- Decision: local
maia agentkeeps PiInteractiveMode. Durable attach uses a thinPiTuiRendererover pinned@earendil-works/pi-tui, only inapps/cli, replaceable because that package is deprecated. No rooms, panels or dashboards;pi-terminal-uiis not ported. - Why: the terminal is a debugging client; it owns no durable state, policy or telemetry truth.
- Consequences:
maia session attachis S12b. Revisit if another renderer implements the same SDK contract better. - Links: Agent terminal surfaces.
D-036 Automatic scoped coordinator learning (2026-09-29) — status: accepted
- Decision: under a human-confirmed, versioned learning policy per Workspace or Project, coordinators author and reuse knowledge, memory and non-executable learned Skills from eligible submitted work, without per-item approval; the Node admits each item.
- Why: owner requirement that knowledge accumulates without "save this".
- Rejected: a manual-only library or a blanket no-automatic-memory rule; ambient history injection; a separate learning backend or always-on loop; implicit cross-Project promotion; rewriting repository or user Skills.
- Consequences: R4 after R10; wider sharing and executable additions still need explicit approval.
- Links: coordinator learning.
D-037 Replace the daily Pi harness by reviewed port and reversible cutover (2026-09-29) — status: accepted
- Decision: every source action is reused, ported with provenance, replaced at its owner, or blocked or accepted as a deviation, using one disposition map. Cutover happens only after an isolated workday, DF-19–21 and user sign-off; Pi stays untouched for rollback.
- Why: drop daily Pi without importing unsafe authority.
- Rejected: copying or symlinking
~/.pi; runtime.pidiscovery; credential copies or bulk history import; a second plugin framework or scheduler. - Consequences: R11 (AG0–AG5).
- Links: Pi harness migration, capability disposition.
D-038 Retention defaults (2026-09-02) — status: accepted, amended by D-188
- Decision: receipts and events 90 days, traces 30, debug logs 7, evaluations 90, artifacts 30, opt-in voice transcripts 7, raw audio never, exports 7 or less. A Project may shorten; lengthening needs user policy and a preview; purges are previewed and leave tombstones.
- Rejected: indefinite retention; raw audio retention.
- Consequences: not enforced yet and no roadmap slice; telemetry retention is set separately (D-045).
- Links: retention.
D-039 Maia configures every harness (2026-10-01) — status: accepted, amended by D-170
- Decision: Skills, instructions, MCP servers, subagents, hooks and
permissions are configured once in
~/.maiaand<project>/.maia; Maia keeps Claude Code, Codex, Pi and Cursor equivalent at user and project level on every Node throughconfig.resolve,plan,apply,driftandimport. Items reach a harness by runtime injection first, then Maia-owned whole files, keyed entries through the harness's own CLI, and marked blocks last. A digest manifest with compare-and-set writes never touches hand-written or harness-owned content. - Why: an agent should behave the same whichever harness or machine runs
it. Prior art (rulesync, ruler, skillshare, agents) showed that replacing
whole MCP maps destroys user entries,
.bakcopies add noise, ignored outputs miss new worktrees and untracked ownership deleted users' files. - Rejected: Maia deciding sync on its own (a Skill or routine calls the operations); copying secrets between harnesses or Nodes; symlinks by default.
- Consequences: S13 (wave 4); each adapter needs recorded fixtures and a check mode.
- Links: harness configuration.
D-040 The skills CLI installs Skills; .agents/skills is the fallback (2026-10-01) — status: accepted, amended by D-170
- Decision:
config.applyruns theskillsCLI (vercel-labs/skills, MIT) throughnpxat an exact pinned version, with telemetry off and copies only, and checks the installed digests against its lock files. Maia's agent falls back to.agents/skillsafter.maiain trusted Projects and records loaded Skills in each Session receipt. - Why: the CLI tracks per-agent Skills paths for about 80 agents, the part that changes most often.
- Rejected: maintaining Maia's own per-harness Skills path table; floating
latest. - Consequences: before release, tests must pin that it never overwrites a hand-written Skill, has stable exit codes and a machine-readable list, never loads a Skill twice, and behaves offline and on Windows.
- Links: Skills installer and discovery.
D-041 Remove the typesafe-ai project Skill (2026-10-01) — status: accepted
- Decision: the
typesafe-aiSkill, installed through theskillsCLI into the main checkout, was removed by owner decision. - Why: owner decision relayed with this documentation pass; no further reason was recorded.
- Consequences: it was never committed, so no repository change was needed.
D-042 One home ~/.maia with versions inside (2026-10-01) — status: accepted
- Decision: everything Maia keeps for a user lives in
~/.maia(orMAIA_HOME): installed versions,current, profiles, runtime files and cache. Services run an exact version, nevercurrent. A development or test install is another version and profile in the same home. An automatic, backed-up, lock-protected migration moves the split state, observability and runtime directories in. A project.maia/never holds secrets, state or installs. - Why: install, update, rollback and removal become directory operations
in one place. Prior art: desktop agent clients that keep
runtime/versions/<version>, pin services to exact versions and snapshot the database before each migration, and Codex (releases/<version>with an atomiccurrentlink). - Rejected: a second home such as
~/.maia-dev; keeping the split layout; picking one layout when both hold live data (stop and report instead). - Consequences: RL3. Isolated tests point
MAIA_HOMEat a temporary directory. - Links: one home.
D-043 Release channels on the 0.1.0 line; publishing paused (2026-10-01) — status: accepted, amended by D-132, D-202
- Decision: one lockstep version for every public package and the desktop
app;
version.txtholds the last stable release and Conventional Commits compute the next. Channels come from the version suffix: stable (latest), next (-rc.N), nightly (-nightly.YYYYMMDD.N) and canary. Build once and test the exact tarballs; stable is promoted, never rebuilt; no waivers; never unpublish; renames keep a 90-day alias. The line stays at 0.1.0 and publishing is paused: onlyv0.1.0-canary.Ntags publish, and only when theCANARY_ENABLEDrepository variable is true; stablev0.1.0is blocked. - Why: a release that breaks an existing install is worse than a late one. Prior art: Hermes Agent's upgrade-from-released-versions suite, desktop agent clients' channels and daemon updates, Kubernetes' skew policy; counter-example: OpenClaw releases that waived gates, renamed packages and daemons without shims and rejected existing state.
- Rejected: forced or waived stable releases, rebuilding for stable, unpublishing, committing pre-release versions, a hosted remote build cache.
- Consequences: RL0–RL5. Pending owner: a public host for the installer and desktop feeds (the repository is private), desktop signing and notarization identities and the updater key, approval to leave canary mode, and whether to configure a 7-day dependency age (only pnpm's default is set).
- Links: release.
D-044 node.hello handshake, skew policy and tolerant readers (2026-10-01) — status: accepted
- Decision: every connection (CLI, SDK, desktop, mobile, MCP, Node to
Node) starts with
node.hello. A different protocol major is refused withprotocol_incompatible, naming the side that is behind and the fix. Clients may be up to two minors older than the Node; Nodes in a pool stay within one minor. Read and handshake responses accept additive fields and enum members; security-, identity-, authority- and mutation-bearing objects still reject unknown fields. CI diffs the operation registry against the last release. - Why: old and new must talk or refuse clearly (Kubernetes skew policy, MCP and LSP negotiation).
- Rejected: the earlier
ProtocolHelloV1sketch with a registry digest and client-kind list (removed from contracts; owner to confirm). - Consequences: RL2. Until the owner confirms the removal, contracts still mention binding dispatch to a negotiated descriptor digest.
- Links: compatibility handshake.
D-045 Local telemetry: Node-owned, local-only, reports not actions (2026-10-01) — status: accepted, amended by D-168
- Decision: each Node keeps its own telemetry in
telemetry.sqlite, separate from the authoritativemaia.sqlite, with closed record kinds, allowlisted attributes and levels (off,basic,detailed,content). Export is a capability with preview, confirmation and receipt. Telemetry produces reports; acting on them is a person's or a Skill's decision. - Why: observe and improve Nodes, harnesses, Skills, Runs, clients and CI without data leaving the user's machines.
- Rejected: OpenTelemetry SDKs or a Collector sidecar; Langfuse, Phoenix, ClickHouse, Postgres, DuckDB or Parquet inside Maia (export files instead); OTLP protobuf or gRPC; raw SQL for clients; rows per token; IDs as metric labels; remote telemetry by default; site analytics; automatic changes; implicit reads of harness transcripts.
- Consequences: T0–T7. Retention targets are proposals to measure.
- Links: telemetry.
D-046 Telemetry defaults per release channel (2026-10-01) — status: accepted, amended by D-168
- Decision: stable and next builds collect the
basiclevel only (local, bounded, no free text). Nightly and development builds collect every level (basic,detailed,content), still local only, with a plain first-run notice andmaia telemetry levelto lower it. Export stays off on every channel until a person enables it with preview, confirmation and a receipt. - Why: owner decision; no further reason was recorded.
- Links: telemetry, release channels.
D-047 Fold @maiaos/telemetry into the Node (2026-10-01) — status: accepted
- Decision: the telemetry package became the
services/node/src/telemetryscope. - Why: the Node was its only consumer, and a package is extracted only for two real consumers.
- Consequences: one package fewer to publish; behavior unchanged (the 25 telemetry tests pass under the Node runner).
- Links: telemetry scope.
D-048 Raw audio is never retained; transcripts are opt-in (2026-09-02) — status: accepted
- Decision: voice keeps no raw audio by default and persists transcripts only by opt-in; partial speech and drafts are not stored.
- Why: voice data is personal; capture for a provider is not consent to keep it.
- Links: AGENTS rules, roadmap voice contract.
D-049 Desktop-first clients: Tauri host and one Expo renderer (2026-09-02) — status: accepted
- Decision: one Expo/React Native app (
apps/client); Tauri v2 (apps/desktop) hosts its local web export, macOS first, then Linux; iOS and Android use native Expo builds. Expo Go is a prototype convenience only. - Why: one app shares features, state, tokens and accessibility; Tauri keeps a narrow native shell; native Expo builds support reviewed audio, notification, deep-link and secure-storage adapters.
- Rejected: React Native macOS (another renderer and qualification matrix), Tauri mobile (duplicate native adapters), a standalone web control app (a public browser authority boundary), Expo Go as a gate, a shared UI package before a second consumer, per-agent websockets, a client-owned scheduler or durable Session.
- Links: closed platform decision.
D-050 Accessible 2D Swarm Canvas; optional 3D (2026-09-02) — status: accepted
- Decision: the canvas is 2D first. A 3D renderer is optional and only behind the same scene contract after identical semantics, an accessible alternative, budgets, reduced motion and an instant 2D fallback.
- Why: lower cognitive and rendering cost; semantic zoom scales.
- Rejected: 3D-first; any 3D-only path.
- Consequences: the canvas is Later; lists, search and an inbox come first, and it is no longer the primary shell (D-053).
- Links: clients.
D-051 Voice is optional input and output for the exact selected Session (2026-09-02) — status: accepted
- Decision: text is the default; voice is a peer input and output mode for the same exact target, never a separate chat or a mandatory coordinator hop. v1 is push-to-talk or an explicit foreground conversation; the microphone is off until activated. Voice may propose but never bypasses confirmation. WebRTC is the preferred transport.
- Why: voice must not become a second authority or a privacy risk.
- Rejected: background recording by default; spoken "yes" as human-only confirmation.
- Consequences: today's Desktop voice uses Vercel AI Gateway Realtime over WSS. Revisit the transport when evidence changes, never to bypass scope or authority.
- Links: roadmap voice contract, clients voice.
D-052 json-render catalog and Jev for product UI composition (2026-09-29) — status: accepted, amended by D-161
- Decision: all product-view composition migrates to one json-render catalog. The Node sends versioned specifications as data through the SDK; authored components keep trusted surfaces and specialized engines. Jev selects and ranks over Node-authorized candidates; deterministic code builds the specification.
- Why: user selection; one composition path for Desktop and mobile with independent client views; typed bounded judgments instead of free-form UI.
- Rejected: json-render only for chat cards; a parallel legacy dashboard; AI-generated React, CSS or layouts; a renderer-owned model backend; hand-maintained duplicate validators.
- Consequences: R9a; three lists already render through the catalog.
- Links: presentation runtime.
D-053 One coordinator chat per scope plus a work panel (2026-09-29) — status: accepted
- Decision: a scope switcher (Maia, Workspace or Project, also on Cmd+K), one coordinator chat and a scoped work panel; on mobile, chat first with work as a sheet. No permanent sidebar, conversation directory, mandatory canvas, feature pages or bottom navigation.
- Why: workers are managed in the work panel; one selector and one result model.
- Rejected: canvas-first shells, permanent portfolio sidebars, arbitrary
Session pickers; the tiling workbench prototype (deleted 2026-10-01,
504c1ab; no roadmap slice). - Consequences: no Session or history is deleted or reparented. The roadmap still promises worker and ordinary-Session chats; the owner should confirm how they appear in this shell.
- Links: UI target.
D-054 @tamagui/core is the only design-system foundation (2026-09-01) — status: accepted
- Decision:
@tamagui/coreandsrc/ui/theme/tamagui.config.tsare the sole theme, token, font and responsive authority. - Rejected: the
tamaguiumbrella package,@tamagui/config, a compiler, a Vite integration, another styling library or a second theme object without a fresh dependency decision. - Links: client rules.
D-055 A local-only Automations demo is allowed (2026-09-17) — status: accepted
- Decision: the inspector shows clearly labelled synthetic automation rows; nothing runs or persists and no SDK operation is called.
- Why: an explicit exception to the no-fake-UI rule.
- Consequences: not evidence for routines (R8).
- Links: Workspace and Project stories.
D-056 Mobile pairing trust model (2026-09-14) — status: accepted
- Decision (owner, 22 October 2026): accept the proposal: a manual fingerprint ceremony with no trust on first use or discovery, Node-scoped device keys, mTLS after pairing, and Tailscale as reachability only.
- Settled in the pairing slice: TLS implementation and key custody (the Keychain adapter is D-122's), private listener configuration, the ceremony and lockout rules, and the connection limits.
- Links: mobile connection proposal.
D-057 Ship @maiaos/node only as an executable (2026-10-01) — status: accepted
- Decision: the Node package drops its library export, types and
sideEffectsfields and ships onlydist/bin(bundle, map, metafile, notices, SBOM, release manifest, native host). - Why: nothing imports it as a library; the tarball shipped the whole tsc output (1,644 entries and 3.0 MB packed, now 10 entries and 1.9 MB).
- Consequences: release checks reject any packed path outside
dist/bin.
D-058 English only, enforced by lint (2026-10-01) — status: accepted
- Decision:
pnpm lint:policyreads source, docs and data files (Markdown, MDX, HTML, JSON, YAML, TOML, text) and rejects French-only words, in CI and the pre-commit hook. - Why: AGENTS.md requires English everywhere; a rule without a check drifts.
- Rejected: words English shares (pour, comment, attention), so fixtures such as "café" stay valid.
- Links: AGENTS rules.
D-059 Scope-first source organization with strict ESLint (2026-09-29) — status: superseded by D-061
- Decision: source lives in owned scopes, each with an export-only
index.tsand colocated tests; no generic folders (common,helpers,shared,types,utils,tests);maia-source/layoutat error severity; no layout baseline or inline disables; phased delivery SO0–SO7 with structural and behavioral changes kept apart. - Why: one owner per responsibility, enforced mechanically.
- Rejected: bucket folders, mandatory service/controller/repository layers, legacy allowlists or blanket ignores, an automatic structural fixer, a new lint dependency, a big-bang rewrite, packages created for symmetry.
- Consequences: every package was migrated on 2026-09-29; legacy lint-policy findings sit in a shrink-only baseline.
- Links: source organization.
D-060 Bounded W-1b execution authorization (2026-08-13) — status: pending owner
- Decision (2026-08-13): the execution gate authorized W0–W2, W2.5, W3, W4a (Vercel Sandbox) and W4e (the owned master VPS) and only their required real-condition evidence. Other providers, W5, production deployment, broad spend, destructive cleanup beyond exact disposable test resources and safety bypasses stayed outside it. Permission was not implementation, proof, enablement or release qualification.
- Open: the current roadmap no longer names W slices; its execution work is S9, S10, S14 and MN0–MN5. Until the owner retires or restates this authorization, its exclusions remain the limit for remote execution work.
- Links: roadmap, Node rules.
D-061 Mechanical naming, size and import rules with a shrink-only baseline (2026-10-01) — status: accepted
- Decision: D-059's scope-first contract stands, and the
maia-sourceESLint rules also enforce kebab-case folders, no generic file names, no file that repeats its folder, at most 12 implementation files per folder, fewer than 4 siblings sharing their first two kebab words, at most 5 folder levels, each test beside the file it verifies, and relative imports that leave a scope through its index. Legacy violations are recorded with their counts in the lint-policy baseline, which only shrinks; no inline disables or severity downgrades. Packages migrate in order: agent, client-core, client and site, protocol, SDK, CLI, and the Node last. - Why: owner direction. The codebase is the agents' memory, so rules with self-explanatory messages keep it organized better than prose, and a shrink-only baseline fails new violations at once without rewriting every package first.
- Rejected: D-059's "no layout baseline", which blocked new rules until a big-bang migration; per-package or ESLint bulk-suppression files beside the existing lint-policy baseline; rules kept only as AGENTS.md prose.
- Consequences:
pnpm lint:policyprints the baseline counts and fails on new or stale entries; each migration prunes its entries. - Links: source organization, roadmap.
D-062 Protocol scopes share Effect Schema through a private entry (2026-10-02) — status: accepted
- Decision: amends D-061 for
packages/protocolonly. A protocol scope keeps its transport-neutralindex.tsand may add one privateeffect-schema.tsentry. Protocol modules and tests import another scope's Effect Schema values through that entry; noindex.ts, the package entry included, imports one. The exception lives in the source-organization policy object and thescope-entry-importsrule, with rejecting fixtures. - Why: the package entry imports every scope index, so every index is part
of the public declaration graph, which must never reach
effect. Effect schemas compose those of other scopes, and the version and limit constants they enforce are public too, so a single entry per scope either leaks Effect into public declarations or closes a cycle. - Rejected: separate
policy/andeffect-schema/sub-scopes in every concept (about 38 folders, each with a test, organized by declaration kind); a declaration verifier that follows only re-exported names (consumer programs would still load Effect declarations); keeping the imports in the baseline. - Consequences: every protocol
index.tsis free of Effect, so scopes with no public surface give their Effect Schema to another scope (the shared wire primitives now sit inenvelope/). The protocol build verifies every package export against its own declaration ceiling and every scope entry foreffect, and a tooling test locks the public export names. Only the declarations stay Effect-free: importing the./feature-registrysubpath loads Effect at runtime (26 protocol modules, tens of milliseconds cold). - Links: source organization, protocol rules, D-061.
D-063 TypeScript 7 compiler beside the TypeScript 6 API (2026-10-02) — status: accepted
- Decision:
tscin every package is the native TypeScript 7.0.2 compiler, pinned in the catalog as@typescript/native(npm:typescript@7.0.2). Thetypescriptname is@typescript/typescript6@6.0.2, which re-exports the TypeScript 6 API (6.0.3 in the lockfile) that typescript-eslint, Expo's config loader, the Next.js build type check and repository scripts import; its only bin istsc6. The shared configs are the canaries@vllnt/typescript@2.0.0-canary.a1749e0and@vllnt/eslint-config@2.0.0-canary.86cefa5. - Why: owner direction (2026-10-02). TypeScript 7 ships no JavaScript
compiler API, so this is the official side-by-side arrangement the vllnt 2.x
configs document. Both canaries keep the 2.0.0 presets; the ESLint canary
also raises
eslint-plugin-functionalto ^10 and pinstypescript-eslint8.68.0. - Rejected: TypeScript 7 under the
typescriptname (it breaks every API consumer); the client's owntypescript@6.0.3beside@typescript/native(twotscbins in one package); the stable 2.0.0 configs (owner direction). - Consequences:
@typescript/nativeis the only package that links atscinto a package'snode_modules/.bin.pnpm lint:policyrequires it through the catalog in every manifest whose scripts runtscand allowstypescriptonly through the catalog. The TypeScript 6 package behind the alias links its owntscundernode_modules/.pnpm, which is not on a script's path. Emitted JavaScript is unchanged; declarations order union members and properties stably, and source maps differ. Revisit the alias when typescript-eslint, Expo and Next.js run on TypeScript 7. - Links: development, lint policy.
D-064 Continuous measurement in the repository, gated on exact metrics (2026-10-02) — status: accepted
- Decision: Maia measures itself with repository tooling and no new
dependency or service. One catalog in
scripts/metrics/catalog/names each metric's owner, unit, better direction, noise class and warn and fail thresholds;pnpm metricswrites a versionedmaia.metrics/v1snapshot andpnpm metrics:comparecompares a base built in a temporary worktree with the head on the same machine. Exact metrics (bytes, counts) gate; wall time is compared with interleaved runs and Welch's t-test on one machine and only trends across machines. AmetricsCI job on pushes to main compares with the last passing push's snapshot, kept as an Actions artifact, and fails on an exact metric past its fail threshold unless aMetrics-Acceptedcommit trailer accepts it. - Why: owner direction (2 October 2026): every update shows its
performance and health changes, and a regression asks for action. Large
projects gate on metrics machine load cannot move and compare wall time on
one machine (rustc-perf, Node.js
compare.js, TypeScript, CodSpeed); shared runners vary by 10-20%. Exact metrics can be measured again from any commit, so Git keeps their history and artifacts suffice. - Rejected: hosted services (CodSpeed, Bencher Cloud, RelativeCI) and new
dependencies (size-limit, a benchmark library), pending owner approval;
gating CI on wall time from shared runners; a
metricsbranch written from CI, which needscontents: write; accepting regressions by editing thresholds in the same change. - Consequences: a failing
metricsjob turns main red until a commit fixes or accepts the regression. A new dependency, a larger lint or source-organization baseline, and an artifact past its fail size each need an accepted reason. Timing suites (node,observation,dx) run on request. The job adds about 3 billed Linux minutes per measured push. - Links: benchmarks, roadmap, development.
D-065 First R0b dependency wave: 7-day release age, Effect 4.0.0, Pi 0.87.1 (2026-10-02) — status: accepted
- Decision: owner-approved on 2 October 2026.
- A dependency version installs only after 7 days
(
minimumReleaseAge: 10080); pnpm re-checks every lockfile entry, fails without a terminal (CI) and asks in one before adding an exclusion. Exempt: the Claude Agent SDK (D-016),effectand@effect/*, and the operator's@vllnt/*packages. An exactws@8.22.0entry, which@effect/platform-node-shared4.0.0 requires, was approved by the operator on 2 October 2026; it has no effect after 3 October 2026, 15:01 UTC and is removed then. - Effect 4.0.0 stable, not a release candidate, with Vitest 5.0.1, which
@effect/vitest4.0.0 requires; itsvitepeer (8.2.1, already installed) is now declared where Vitest runs. The coordinator and voice tool JSON Schemas stay closed, a failed unknown-outcome fence still wins over the dispatch failure, and the client's Babel workaround for the EffectContextgetter is removed. The CLI standalone ceiling rises from 256 KiB to 277 KiB: Effect 4.0.0 grows the executable from 259,700 to 279,060 bytes. - Pi follows the newest release outside the 7-day window that adds no built-in extension: 0.87.1, with Pi's prompt-cache warming off on the SDK paths and in the Node's RPC sessions. Pi 0.99 and later wait for a test that proves their built-in MCP, codemode and llama.cpp extensions stay off on the SDK paths.
- Removed:
typebox(agent;Typecomes from@earendil-works/pi-ai),@tailwindcss/typography,@mdx-js/react,postcssandautoprefixer(site), andlucide-react-native(client; six localreact-native-svgglyphs, with Lucide's notice inthird-party/lucide).
- A dependency version installs only after 7 days
(
- Why: owner direction for R0b: Effect everywhere and only the dependencies the product strictly needs.
- Rejected: pnpm's one-day default; Effect 4.0.0-rc.118; Pi 1.0.0 now;
overriding
wsbelow the range Effect declares; keeping the Babel workaround after its negative control stopped failing. - Consequences: a Pi upgrade re-checks the
Typere-export and the built-in extensions; the release-age exclusions need a dated operator approval. Deferred:jsonc-parserand the unwired Node configuration code, the React Native peer-only packages (native build check),yamlandxstate. Update (31 October 2026): F7a removed the unwired Node configuration resolver, writer and parser andjsonc-parser; S13 owns the configurator. - Links: roadmap, release, CLI rules.
D-066 R0b gates: Effect and test-quality lint with shrink-only baselines, measured coverage (2026-10-02) — status: accepted
- Decision: owner-approved on 2 October 2026.
- Effect applies wherever it can; boundary adapters stay thin and are
allow-listed per file and capability. The
maia-effectESLint rules (Promise orchestration, restricted imports, raw timers andfetch, ambient reads, module state, error classes and the console) run at error severity on application code in every workspacesrc/. The code that predates them is counted in theeffectBoundariessection of the lint-policy baseline, which only shrinks (D-061). - New
xstateimports fail now, with the existing ones in that baseline: XState will be replaced by Effect. - In
scripts/, Effect applies only to scripts that orchestrate processes; the lint does not cover repository tooling. - Tests prefer real boundaries over mocks of owned code. The
maia-testrules reject module mocks of owned modules,as unknown asinto owned types, wall-clock sleeps that sequence a test andvi.useFakeTimersin Effect tests, with atestQualitybaseline that only shrinks. - Coverage is measured, not gated yet: Node's built-in coverage for the
node:testsuites, and@vitest/coverage-v8for protocol and Node, pinned to the exact Vitest version.
- Effect applies wherever it can; boundary adapters stay thin and are
allow-listed per file and capability. The
- Why: owner direction for R0b. A ratchet stops new gaps while packages migrate one at a time, and a mock of owned code can keep a test green while the real path is broken.
- Rejected: core
no-restricted-syntaxandno-restricted-importsentries (a local rule override, which lint-policy forbids, and they would replace the shared profile's ownno-restricted-syntax); allow-listing whole folders, which would hide unrelated gaps beside an adapter; a second baseline file; coverage floors before the numbers exist;c8or another coverage dependency fornode:test. - Consequences:
pnpm lint:policyprints and checks every ledger; each EF2 and TQ1 migration prunes its entries; an allowance a file no longer uses fails. Floating promises were already an error through the shared profile's@typescript-eslint/no-floating-promises. Coverage runs on request, not on every push. - Links: Effect boundaries, testing policy, roadmap.
D-067 Pi 1.0.0 with its built-in extensions held off (2026-10-02) — status: accepted
- Decision: owner-approved on 2 October 2026.
@earendil-works/pi-aiand@earendil-works/pi-coding-agentmove from 0.87.1 to 1.0.0, published on 1 October 2026.- Release age: official Pi packages (
@earendil-works/pi-*) install as soon as they are published.@earendil-works/chord, which pi-coding-agent 1.0.0 requires as^1.0.0but never imports, has one exact@earendil-works/chord@1.0.0exclusion, removed after 8 October 2026, 19:13 UTC. Third-party packages keep the 7-day wait; none needed an exclusion. - Pi's built-in codemode, llama.cpp, MCP and tool-search extensions stay off
on every Maia path. SDK sessions (local
maia agent, the managed worker) load a built-in only when the caller registers it; Maia registers none, passesnoExtensions, and an explicitbuiltin:<name>path fails to load. Node's Pi RPC sessions pass--no-extensions, which alone keeps them off; the Node-writtensettings.jsonalso disables all four by name, which holds while--no-approvekeeps the project untrusted. The installed-Pi runtime probe relies on the same flags, which the launch authority requires. - Tests pin the exact extension, tool and command lists on each path, with
controls where the built-ins do load: Pi's SDK with its built-ins opted
in (it starts the configured MCP servers, follows a hostile
~/.pi/agent/models.jsonand reads the profile) and an RPC session without either guard. Localmaia agentand both managed worker modes, run in that hostile profile, read nothing under~/.pi. - Two older ambient reads are closed: the managed worker's
session.showprojection read~/.pi/agent/models.json, which could redirect the leased credential's provider endpoint, because it never bound Pi's agent directory; and SDK sessions took the working directory's.pi/SYSTEM.mdand.pi/APPEND_SYSTEM.mdinto the system prompt. - On 0.87.1 the Node's RPC sessions still loaded the llama.cpp built-in
(
/llama) despite--no-extensions; 1.0.0 drops it. - Both Node Pi launches drop the Anthropic workload-identity variables Pi
reads since 0.99.2 (
ANTHROPIC_FEDERATION_RULE_ID,ANTHROPIC_ORGANIZATION_ID,ANTHROPIC_IDENTITY_TOKEN_FILE): the RPC environment is a fixed allow-list, the managed worker gets onlyLANGandLC_ALL, and the process manager refuses a launch that carries one. - Kept: prompt-cache warming off on every path, and Node's compile cache off in RPC sessions (Pi 1.0.0 still enables it). Persistent RPC sessions require the exact 1.0.0 package; the session file format stays version 3. Pi now writes a new session file at the first user message rather than the first reply, which the Node needs no change for.
- The retained Pi license moves to
third-party/pi/1.0.0(unchanged bytes at the v1.0.0 gitHeada13d35a7) and now also covers pi-mcp and pi-codemode, which reach the Node executable; pi-mcp adds its MCP TypeScript SDK notice.
- Why: owner request to take Pi 1.0.0; D-065 held 0.99 and later until the built-in extensions were proven off.
- Rejected: one exact exclusion per Pi package (the owner chose the
name-level rule);
@earendil-works/*, wider than needed; a pnpm override that removes chord from Pi's dependencies; waiting until 8 October. - Consequences: local
maia agentopens Pi 1.0.0's fullscreen terminal UI by default;/settingsswitches it for the session. Sign in with ChatGPT, new on theopenaiprovider, needs a stable device ID that Maia does not store yet, somaia login openaiwith OAuth fails before any network request; the API key works. Pi still checks whether a Google application-default credential file exists under~/.config/gcloud. A later Pi upgrade re-runs the built-in extension tests and re-checks this notice set. - Links: Agent architecture, roadmap, release.
D-068 An Effect-native SDK core behind the Promise API, with one Effect entry (2026-10-02) — status: accepted
- Decision: owner-approved on 2 October 2026 (R0b EF2).
- Every SDK operation is an Effect value first: request validation,
transport, correlation and response checks run in Effect and fail with
MaiaClientError. The public Promise API (LocalNodeClient, themake*Clientfactories,createMaiaClientand@maiaos/sdk/node) keeps its declarations and behavior as a thin facade that runs each operation once. - Each SDK scope keeps its Effect-native operations in one private
effect.tsentry, which other scopes and the scope's facade import and noindex.tsdoes, so the root,./nodeand voice declarations never reacheffect(D-062's private entry, extended to the SDK). - One new subpath,
@maiaos/sdk/node/effect, exports what an Effect program on Node.js needs:connectNodeApiEffect, the four attached Session and context operations,createLocalNodeEffectClientand their types. Its declarations reacheffect. The Effect discovery and attached operations take theCryptoservice from their caller for message identifiers; the Promise facade provides one built onnode:crypto. - The CLI runs every command as an Effect program on one runtime at the
process entry (
runProcessMain). Its library entry'smakeCliandrunClireturn anEffectof the exit code; usage errors are the typedCliArgumentError, and exit codes, JSON output and help text stay as they were.
- Every SDK operation is an Effect value first: request validation,
transport, correlation and response checks run in Effect and fail with
- Why: owner direction: Effect callers (the CLI now, the clients later) stay in Effect end to end, with typed errors and interruption that reaches the HTTP request, while third parties keep the Promise API. A subpath is the smallest option that lets the CLI use the surface: the CLI's published library entry imports it, so it must exist in the published package too.
- Rejected: Effect types in the existing public declarations; an export
removed on publish (the published CLI library would import a missing
subpath); a platform-neutral
@maiaos/sdk/effectnow (no consumer yet, and Node discovery stays under./node); an Effect surface derived from the Promise client (Promise stays the core, so interruption never reaches the transport). - Consequences:
@maiaos/sdk/node/effectis a public subpath with the same canary status as the others, listed in the release contract. A browser or mobile Effect consumer adds a neutral entry when it needs one. Thefetchdefault ofNodeApiTransportOptionsis one allow-listed module. The CLI standalone grows from 279,060 to 291,324 bytes, mostly Effect's Node child-process spawner and file system for the vendor sign-in, so its ceiling rises from 277 to 288 KiB, and Node's startup budget, which mirrors it, moves with it. Starting the Effect runtime before every command adds about 10 ms to the cold start ofhelpand--version; Effect Schema,Streamand Node'sStdioload on first use, because on the startup path they would add about 10 ms more. - Links: SDK reference, SDK rules, source organization, Effect boundaries.
D-069 Reviewed port of the owner's Pi harness into Maia agent (2026-10-02) — status: accepted
- Decision: owner-authorized on 2 October 2026: take inspiration from the
local Pi harness (an earlier private harness configuration) and import every useful feature into
Maia agent, except agent-to-agent communication, which Maia Node and the
CLI own.
- Provenance: code comes only from files the pinned commit
(
b90808fbd2e51301ab8351367c69c5166c44ead7) tracks, read as data. Each ported file names its source path and commit in a header comment, and the port record links each one. Credentials, sessions, runtime state, untracked files and uncommitted changes are never read, and the source harness is never run or changed. - Classes: A in-session behavior with no durable authority is ported
into
packages/agent, rewritten for Effect and the repository lint rules; B durable or cross-session behavior is re-homed on the slice that owns it (R11 port map), and the agent later calls that operation through the SDK or CLI; C agent-to-agent and RPC plumbing is excluded; D behavior that Pi 1.0 or Maia already covers, or that needs an ambient credential or a second paid model, is dropped. A network tool is ported only under a Node-granted capability with no ambient key; none qualified. - Ported (A): the full pi-safety parser, the pi-search bounds,
/goaland the goal prompt frame,/tasksand the open-task digest, heuristic naming of unnamed sessions and the pi-prompt-conformance tool-metadata rules as a contract test, which reworded thegoalandprocesstool guidelines on both paths. Commands, prompt sections and naming are standalone-only; of the new extensions the managed worker gains only the search bounds. - Pi's
findandgrepstay inactive: when they run, Pi looks forfdandrgunder~/.pi/agent/binfirst, because it fixes that directory before Maia binds its own. Enabling them waits for R11 AG1.
- Provenance: code comes only from files the pinned commit
(
- Why: owner request. The source holds tested behavior worth keeping,
but most of it assumes an ambient
~/.piprofile, ambient credentials or session-local durable state that Maia Node owns. - Rejected: importing the extensions unchanged (ambient
~/.pireads, credential rewrites, Promise orchestration); shipping the eight subagent definitions as Node agent definitions, whose schema has no tool grants or model profile and whose activation the Node refuses; porting the Luna and Vercel model calls (title, router, verify, compactor), which need extra provider credentials. - Consequences: the D-067 lists now pin six inline extensions and the
tasksandgoalcommands on the local path, and five inline extensions and no command on the managed path. The per-action AG0 manifest and the cutover stay with the migration plan. - Links: port record, Agent architecture, roadmap.
D-070 One coordinator plugin for Claude Code and Codex, held to a measured MCP budget (2026-10-02) — status: accepted
- Decision: owner-directed usability pass for Codex and Claude Code
swarms.
- The
maiaplugin (plugins/maia, listed by.claude-plugin/marketplace.json) is the only coordinator package for both brains: Codex 0.160 and later install it from the same marketplace with its Skill andmaia mcpserver. Without the plugin command, Codex takes a[mcp_servers.maia]entry and a linked Skill. - The
maia-orchestrationSkill leads with the MCP tools and states which result carries each next revision; Node errors name the field, the current revision, the account state or the next step through the existingmessage, never new fields. - One end-to-end test drives the Skill's loop through the built
maia mcpagainst an in-process Node whose Codex and Claude Code executors are fakes, and fails when the loop needs more tool calls, a result grows past its byte budget or a step slows down. The fakes enter throughrunDesktopNodeWithExecutors, which the package entry does not export, so themaia-nodeexecutable never contains them; the test checks both.
- The
- Why: owner direction to pilot Codex and Claude Code swarms quickly. The audit found that Codex already reads the Claude Code marketplace, so a second package would only add drift; and that the brain's cost lives in round trips and result size, which only a test through the real MCP server measures.
- Rejected: a separate
.codex-pluginpackage oragents/openai.yaml(Codex reads the existing files; HPS0 keeps that file absent); fake vendor executables speaking the Codex app-server and Claude stream protocols (large fakes of third-party protocols for no extra coverage of Maia); enabling the fakes in the built Node behind a test-mode variable (they would ship in the executable); new structured hint fields in the error shape (it stays stable). - Consequences: a change that adds a round trip or bloats a coordinator
result fails
coordinator-mcp.integration.test.ts; raising its budget records the reason in the usability audit. Error message texts changed; codes, retryability and JSON shapes did not. - Links: usability audit, vendor executors, roadmap S4a.
D-071 Gateway Connections: an AI gateway's URL and key for every executor (2026-10-02) — status: accepted
- Decision: owner request of 2 October 2026 (S24, swarm fast track step 1).
- A Connection may be a
gateway: a base URL (https://, orhttp://to a loopback host, canonical, without credentials, query or fragment) and an API key. The gateway, not Maia, holds the provider accounts, models and quotas; the Node keeps Session caps, holds, receipts anddisable. - The key is stored the way the Node treats
auth.json(D-010): onegateway-keyfile, mode0600, one hard link, opened without following a symbolic link, inside the Connection's Maia-managed root. It never reaches.maia, the database, the journal, receipts, events, logs, output or a process argument; the request digest excludes it. The CLI reads it from stdin only. - Launch adds exact variables in one place
(
services/node/src/harness/gateway): Claude Code getsANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN(bearer) andANTHROPIC_MODELwith a per-SessionCLAUDE_CONFIG_DIR; Codex gets amaia-gatewaymodel_provider(wire_api = "responses",env_key = MAIA_GATEWAY_API_KEY) through-coverrides on an empty per-SessionCODEX_HOME, and the key inMAIA_GATEWAY_API_KEY. connection.models.listreads/v1/modelsonce (1 MiB, 10 seconds, no redirects) in OpenAI or Anthropic form; the coordinator MCP may call it. Creating a gateway is human-only.- Schema v45 marks a gateway with a
connection_gatewaysrow beside aconnectionsrow whose stored executor is an inert placeholder, because the v38 CHECK cannot be rebuilt while Sessions reference the table (D-012). Session bindings gain an optionalmodel.
- A Connection may be a
- Why: the owner wants to use models from one gateway through Codex and the Claude Agent SDK without adding provider accounts to Maia.
- Rejected: a
config.tomlin the Codex home (a file write for values that-calready carries);ANTHROPIC_API_KEYfor Claude (gateways generally accept the bearer form); following redirects for the model list (the key could reach another origin); Pi and Maia agent wiring now (Pi Sessions bind provider pools, not Connections, and S23 is paused by the owner). - Consequences: a subscription-backed model proxy can breach the
provider's terms. API-key gateways such as Vercel AI Gateway are the default
recommendation, and the owner decides per gateway. Gateways take no part in
--executor-only account selection and record no vendor usage or limit window. Secret storage beyond the owner-only file stays pending with D-010. - Links: executors, providers, connection protocol.
D-072 Remote Nodes are installed and reached over SSH, the API over SSH stdio (2026-10-02) — status: accepted
- Decision: the swarm fast track's "SSH first" (owner, 2 October 2026).
maia-node remoteprobes a Linux host over SSH, installs a pinned runtime as a systemd user unit and rolls back or uninstalls it; the client then reaches that Node withssh <host> <root>/profiles/<profile>/bridge, which runsmaia-node bridgeand carries the Node API's own request and response envelopes, one JSON object per line. The remote Node keeps its loopback-only listener, its bearer never leaves the host, and the client side is the SDK'sMaiaTransport. Every remote script travels base64 behind one fixedsh -cbootstrap. - Why: it works wherever SSH works (no new port, no TCP forwarding, no certificates before MN1), reuses the user's SSH keys and host verification, keeps one Node API and one client, and lets the bridge prove the Node's identity on the host as every local client does.
- Rejected: a remote HTTPS listener before MN0/MN1 authentication;
ssh -Lport forwarding, which adds a local listener and moves the bearer to the client; Tailscale-only reach (it stays optional reachability, and never Funnel);maia mcprun on each host, which needs the CLI there. - Consequences: whoever can SSH in as that user holds the Node's local
authority, as on the host itself. A request costs about one network round
trip (VPS over Tailscale under load: read p50 42 ms, mutation p50 85 to
101 ms at a 20 ms minimum round trip). Observation streams (S12) and
node.hello(RL2) are not bridged yet. The SDK, CLI (maia node add|list,--node) and MCP expose it next, on the Effect-native SDK (D-068). Each Node signs in its own accounts. - Links: remote install, roadmap S10 and S14.
D-073 The Run ledger keeps its own tables beside the local-v1 terminal Run (2026-10-02) — status: accepted
- Decision: Runs whose dispatches are executor Session turns (S19) live in
four tables (
run_ledgers, Tasks, dependencies, dispatches) behindrun.ledger.*, beside the unchanged local-v1 terminal Run (run.createand its siblings). They share the local-v1 Task input schema, graph bounds and topology checks, the command journal and receipts, and turn storage. A Task's state is derived from its dispatches and never stored. A dispatch queues the Task's objective as a turn in the transaction that records it, under a turn command id derived from the dispatch's, so a retry replays both. - Why: the local-v1 tables cannot hold Session dispatches.
orchestration_runs.terminal_idis NOT NULL and references a terminal that must be an unavailable record;orchestration_attemptsallows one attempt per Run (ordinal = 1, unique per Run); a Run has one dispatch; Task states have no succeeded or stopped; and the protocol aggregate enforces that one-dispatch topology. Every foreign key isON DELETE RESTRICT, which SQLite checks at once even when checks are deferred, and execution placements and relocations reference the Run and attempt tables, so relaxing any of it means a delete-and-reinsert rebuild of about twelve tables inside the one migration transaction. - Rejected: rebuilding the orchestration tables and generalizing the
local-v1 aggregate (large and risky, and inside the
run.inboxandrun.questionarea S17 owns); retiring local-v1 now and giving its operation ids to the ledger (a breaking change to registered operations); a second task or claim engine (D-028). - Consequences: two Run storages, and two Task tables with different
state names (local-v1
pending,active,waiting-question,failed; ledgerblocked,ready,active,succeeded,stopped), until the owner converges or retires local-v1 (roadmap lists its consumers). The CLI grammar ismaia run ledger …. Worker Sessions cannot call ledger operations until S18 roles exist. Reopening a stopped Task is not built. - Links: Run ledger, protocol, D-028.
D-074 Session credentials by launch environment, roles by protocol table, guards per Project in the Node (2026-10-02) — status: accepted
- Decision: S18a and S18b, as D-031 asked.
- A Codex or Claude Code launch gets its own credential in the launch
environment (
MAIA_SESSION_CREDENTIAL, withMAIA_NODE_ORIGIN), sent asAuthorization: Maia-Session. The Node keeps its digest, revokes it when the runtime ends, on relaunch and at Node start, and resolves it only while the Session is live. - The protocol's
SESSION_ROLE_OPERATIONStable lists what aworkerand anorchestratorreach; the Node checks it before any handler and scopes each operation to the Session (role_deniedotherwise). - Guard limits live in the Node, per Project, set by a person with
project.guard.previewandproject.guard.confirm, and are checked inside the write transaction. - On Linux the process host denies worker trees file reads of the runtime directory and auth file by granting reads beside every ancestor (Landlock grants by inclusion); the helper gets the paths in its one environment variable.
- A Codex or Claude Code launch gets its own credential in the launch
environment (
- Why: a worker's tools (shell commands,
maia,maia mcp) inherit the environment, while the inherited Invocation attachment pipe reaches neither vendor tool processes nor Linux. A repository-controlled.maia/file would let a coding worker raise its own limits. - Rejected: the operator's bearer plus a restricting header; extending
the Darwin-only Invocation attachment (it stays the paused Pi path until
S23 converges them); limits in
.maia/config.jsonc; a new launch-frame protocol version for the denial paths. - Consequences: on Linux the Node needs
XDG_RUNTIME_DIR, because a runtime directory under/tmprefuses worker launches, and files created later directly in$HOMEare unreadable to workers. An environment is readable by the same user's processes (ps -Eon macOS), so the credential is bounded by role, Session liveness and a 24-hour expiry. macOS has no read denial. Workers reach the Node only once S5 or S18c give them network and a client. - Links: worker credential, safety.
D-075 Coding Sessions write in a Node-created worktree of their own (2026-10-02) — status: accepted, amended by D-174
- Decision: a coding Session (S5) is a vendor executor Session created
with a base ref. The Node creates a worktree Workspace for it through the
existing worktree owner, on branch
maia/<workspace-id>, and the worker writes only there: Codex through themaia-writeprofile (the read-only profile with its worktree writable), Claude Code through its editors under the PreToolUse guard and Bash in Claude's sandbox. The Project's.maia/worktree.jsonin its canonical checkout lists the git-ignored files to copy and one setup command.session diffreads the change;session archivestops the Session, commits its work to the kept branch as Maia, and the Node removes the worktree an hour later throughexecution.worktree.remove, whose operation is the cleanup proof. - Why: swarms need workers that change code without reaching other folders, other worktrees or credentials, and a person reviews and integrates their branches. Reusing the worktree owner keeps one creation and removal path with its identity checks and dirty refusal.
- Rejected: letting workers commit (their sandbox cannot write Git metadata outside the worktree, and should not); setup and copy lists from the base ref's own files (a worker-authored branch could then plant a command the Node runs unsandboxed); removing the worktree inside the archive request; a new process tier for Project setup commands without a person's approval.
- Consequences: setup commands run as managed Project commands, which a
Node admits only under the test-only execution command authority today, so
coding Sessions fail to launch while a Project lists one. Workers cannot run Git commands that need the
repository metadata or toolchains outside the minimal system paths.
Sessions in a Maia-created worktree Workspace now launch from that
worktree's own root identity, and the Claude guard refuses
~paths. The Node checks a worktree's.gitfile before running Git there and pins the Git directory, because a write-capable worker could otherwise point Git at its own filters. Claude's file tools stay outside its sandbox, so a worker racing a link swap past the guard remains a known limit of this cooperative tier. - Links: coding Sessions, isolation and review, roadmap S5; Codex Git read access amended by D-101.
D-076 Desktop reaches the Node through descriptor exposure and native confirmation (2026-10-02) — status: accepted
- Decision: desktop-plan P0 and P1.1–P1.3, P1.6, under the owner's
direction to make Desktop a fast daily driver for many Sessions.
- Protocol feature descriptors are the Desktop allowlist. An operation that
exists on the Node is exposed to the renderer only with
desktop: "reviewed-renderer-local-node"; Rust keeps the same list with each risk class innode_methods.rs, and registry parity fails on any difference. 60 operations are exposed;terminal.observer.attach,terminal.observer.resyncandterminal.controller.reneware not, because the Node reports them unavailable. - One
node_requestcommand replaces the per-method native validators. Rust keeps the exact SDK envelope, theinvocationban, size, nesting, deadline and concurrency bounds and the bearer; the Node's strict decoding owns parameter validation. - The descriptor risk class decides native confirmation:
consequentialanddestructiverequests wait for the user's answer in a native macOS sheet that renders the exact request Rust will send.turn.sendandinteraction.respondare consequential (an agent acts on them); starting, stopping or resuming a Session, interrupting a turn, cancelling a question and registering a Project are reversible and not prompted. Terminal signals and close are gated too. - Owner decision (2 October 2026):
turn.sendandterminal.input.writeare exempt from the sheet and go throughnode_requestlike the CLI. Their risk class stays consequential; the exemption is an explicit Rust list that registry parity checks against the descriptors. Daily chat speed is chosen over protection against a compromised renderer sending messages. The compensating controls are the content security policy (no remote scripts,script-src 'self'with nounsafe-inlineorunsafe-eval) and agent output rendered as plain text or sanitized markdown with no raw HTML. - Review fix:
terminal.createis consequential, so a terminal needs one sheet when it is created, and its input then flows without one. Starting a Session is not gated. A compromised renderer can drive agents within their executor sandbox and profile (S5, S18), but cannot get an unsandboxed shell without a sheet. It can still type into an existing interactive terminal after taking its controller lease. - Desktop uses the profile's ready Node or starts the configured
maia-node --desktopas a supervised child and stops it on exit; it never installs a service (RL3).MAIA_DESKTOP_PROFILEselects a named profile.
- Protocol feature descriptors are the Desktop allowlist. An operation that
exists on the Node is exposed to the renderer only with
- Why: the renderer shows untrusted agent output, so it must not be able to approve its own consequential requests, and hand-written per-method validators duplicated the Node's schemas without a parity check.
- Rejected: a renderer-side confirmation (a compromised renderer can
forge it); classifying
turn.sendas reversible to avoid a prompt per message (its effect is an agent acting, which is not reversible); adding the Tauri dialog plugin (the sheet uses AppKit through the existing Objective-C helper build, with no new dependency); a time-boxed per-Session send grant (the owner chose the exemption instead). - Consequences: a compromised renderer can send messages to agents and
type into terminals that already exist. Bulk Session creation needs no sheet,
because create, start and the first
turn.sendare all unprompted. Linux fails the remaining gated requests closed until a reviewed dialog exists. The native sheet has no automated visual qualification yet.
D-077 Live Session item stream: one writer, two lanes, the reducer in the SDK (2026-10-02) — status: accepted, amended by D-168
- Decision: S12b streams Codex Session items as built in the
Node architecture.
- One writer per Session assigns the owner sequence. The live lane
coalesces deltas for 33 ms and offers each attachment a bounded queue
(256 records, 2 MiB); a full queue gets
resyncinstead of slowing the writer or other viewers. The durable lane commits finished items in one transaction per 400 ms insession_stream_records; deltas are never durable. Startup closes items and turns a crashed epoch left open. session.attachis a second stream operation on the observation socket, authenticated by the local bearer likeobserve.events: subscribe to the live lane, replay from the{ epoch, sequence }cursor or send a bounded snapshot (over 1,000 records or 8 MiB, or another epoch),synchronizedwith each open item's text, then live records. It is view-only and has no agent or MCP exposure. The CLI parity check gains a typed stream inventory (session attach→session.attach→attachSessionEffect).- The one reducer,
reduceSessionAttach, lives in@maiaos/sdk:@maiaos/cliis a public package and@maiaos/client-coreis private, and the release check (scripts/release/npm-packages.mjs) rejects any unknown@maiaos/*dependency of a public package. The client-coresession-attachentry re-exports it unchanged for Desktop and mobile. Making client-core public was rejected for now (owner decision, 2 October 2026). - Codex fixtures are recorded from the real
codex app-server0.160.0 with a privateCODEX_HOMEagainst a scripted Responses model on loopback (services/node/scripts/record-codex-app-server.mjs): no account, credential or network. - The CLI standalone ceiling rises from 306 to 312 KiB for the stream schemas, the SDK attach stream and reducer and the renderer.
- One writer per Session assigns the owner sequence. The live lane
coalesces deltas for 33 ms and offers each attachment a bounded queue
(256 records, 2 MiB); a full queue gets
- Why: the acceptance budget (delta to CLI line p95 under 100 ms, at most three durable writes per second per Session) needs live delivery that never waits for SQLite, and durable structured items that a later context rebuild (AG3a) can read without deltas.
- Rejected: a PubSub fan-out (an unbounded subscriber slows the producer); one durable row per delta; a separate attach WebSocket path (a second admission and authentication surface); duplicating the reducer in the CLI.
- Accepted risk: records keep what the harness shows a person, as
reported: commands, tool arguments, output, diffs and absolute worktree
paths. Maia applies no heuristic redaction, which would miss secrets and
corrupt evidence; the Codex worker profile denies credential reads, and
raw or encrypted reasoning never enters a record. A secret a command
prints stays in
session_stream_recordsuntil a purge exists. Rows refuse updates, not deletes, so a purge needs no schema change. - Consequences: Claude Code and Pi adapters (S12c) and the Desktop timeline (S12d) reuse the writer, the vocabulary and the reducer. Retention is unbounded: a purge when a Session is archived (S5) and an age limit are the S12 follow-up S12r in the roadmap, pending the owner's retention choice.
- Links: roadmap S12, contracts, CLI reference.
D-078 Adopt native sessions, don't copy them; read metadata unless asked (2026-10-02) — status: accepted, amended by D-171
- Decision: owner-approved as the Desktop plan's import decision. Maia
finds existing Claude Code and Codex sessions and adopts them in place
instead of copying transcripts.
- Discovery reads metadata through the vendors' own interfaces (Claude
Agent SDK listing,
codex app-serverthread/listwithuseStateDbOnly). Titles, first prompts and previews are content and need an explicitincludeContentFields. session.history.readis its own operation: a bounded page through the vendor, never stored, logged or measured. The operator and their coordinator may call it; Maia-launched workers may not, because read access is not disclosure permission.- Adoption requires evidence that nothing else runs the session: Claude's
sessions/<pid>.jsonor, for Codex, a person's attestation. Confirmation rechecks it and claims the native session insession_adoptions(unique per executor and native ID), creating a stopped Session on the vendor's system-default Connection in the Workspace's primary checkout. - A third-party workspace importer first shipped with this entry; D-082 removed it.
- Discovery reads metadata through the vendors' own interfaces (Claude
Agent SDK listing,
- Why: onboarding from Claude Code and Codex without a second transcript store, without content leaving its vendor store by default, and without two clients resuming one session.
- Rejected: copying transcripts into Maia (a second source of truth for personal content); reading Claude or Codex JSONL directly (vendor format drift; the SDK and app-server are the supported readers); process probing for liveness (Maia has no identity-bound process helper and never signals processes it did not launch).
- Consequences: a stale Claude pid record after a crash reads as live and
blocks adoption until Claude cleans it; an unreadable record refuses
adoption rather than reading as ended. Codex adoption depends on a
person's attestation. Every resume of an adopted Session rechecks the same
liveness evidence and refuses with
adoption_refusedwhile another client runs it. Migration 49 addssession_adoptionsanddiscovery_previews; migration 52 keeps the previews to adoptions.- A Codex scan writes no thread state but is not write-free. On a copy of
state_5.sqlitein a temporaryCODEX_HOME,codex0.160thread/listleft the database byte-identical with an empty WAL and created no rollout file, yet app-server startup appended to its ownlogs_2.sqlite, opened its goals, memories and queue databases and synchronized bundled system Skills. On the live~/.codexthose files already exist, so a scan adds log rows; concurrent Codex use changed every watched file within 20 seconds, so live snapshots cannot attribute smaller effects. - History reads page paginated Codex threads through
thread/turns/list; legacy threads keepthread/readwith turns, which Codex deprecates only for paginated threads.
- A Codex scan writes no thread state but is not write-free. On a copy of
- Links: vendor executors, CLI, roadmap S11.
D-079 The work panel hosts a scale-first cockpit; a Session opens as a focused detail (2026-10-02) — status: accepted
- Decision: owner direction on 2 October 2026, answering the open
question of D-053 and keeping its scope-first shell:
- the work panel hosts a cockpit: an attention list, paged and filtered
lists of every Session, and later the fleet board. Until coordinator
chats exist (R3) it sits beside the single-session voice surface on wide
layouts, maximizes to the full width (
mod+\), and opens as a full-screen Work sheet on compact layouts; - any Session opens as a focused detail inside the work panel (timeline, composer, later approvals and diff), never as a sidebar thread or a second shell;
mod+Kopens one launcher for scopes, Projects, Sessions and commands. Choosing a Workspace or Project narrows the cockpit and changes no execution focus; a Session jump stays inside the current scope;- keys come from one command registry with stable command IDs, a rebindable keymap and conflict detection;
- creating many Sessions is one flow: task lines, an exact preview with
fixed command IDs and a digest, one confirmation, per-item receipts or
Node refusals, and retries with the same commands. A batch holds at
most 32 items and never targets
all.
- the work panel hosts a cockpit: an attention list, paged and filtered
lists of every Session, and later the fleet board. Until coordinator
chats exist (R3) it sits beside the single-session voice surface on wide
layouts, maximizes to the full width (
- Why: owner direction ("fast, keyboard shortcuts, easy to use, easy to add tons of tasks and agents running"); a flat thread sidebar does not scale to dozens of Workspaces and hundreds of agents.
- Rejected: a permanent thread sidebar; one stream or poll per row or Project; a client-side batch operation the Node does not know.
- Consequences: client-core owns the
commands,node-registry,cockpitandsession-viewscopes; React renders them. The timeline shows final turn text until S12b streaming plugs into its typed source. On Desktop no step of a batch opens the native sheet (create, start andturn.sendare unprompted), so the renderer preview and its one confirmation are the batch's confirmation; first turns still go out one at a time. - Links: UI target, client architecture, client-core.
D-080 The local Node registers remote Nodes and the CLI reaches them over the SSH bridge (2026-10-02) — status: accepted, amended by D-176
- Decision: the local Node owns
node.remote.installandnode.remote.remove(preview, then a confirmation bound to the plan digest, the inputs, the actor and the registry revision, then a receipt) andnode.remote.list, records each remote Node in SQLite and runs the existing remote install module in a Node-scoped job; requests long-poll under the 5-second Node API deadline. The bridge frames are a protocol contract, and the bridge client, frame codec and ssh arguments live in@maiaos/sdk/node/effect, which takes a spawned process and never runs ssh.maia node add|list|removedrives the operations; a leading--node <name>routes any Node-backed command,maia mcpincluded, through one bridge per invocation. The install pinscodexandclaude(and a script's Node.js interpreter): after checking every directory a link or the file sits in, it records each path and digest and puts their directories on the unit's PATH with the Maia runtime and the system directories, never the login PATH; launches do not re-verify the digests. - Why: the owner-approved S14 plan: one authority for remote Nodes, one install implementation, one SDK transport, and executors the Node can prove rather than whatever a login PATH finds.
- Rejected: a separate probe read (the add preview carries the facts,
blockers and executors); the CLI installing directly (a second authority);
copying sign-ins between Nodes (
maia --node <name> loginruns the vendor sign-in there withssh -t); trusting a group-writable directory. - Consequences: the local Node spawns ssh for remote jobs outside the
process resource manager; a job a restart cut short ends as failed and is
resumed by running add or remove again. A Node routed with
--nodemust beready, greet as its recorded profile, and never falls back to the local Node. On a host whose~/.local/binis group-writable (Ubuntu's 775 default) the discovered executors are unavailable until the owner tightens it or passes an override from a private directory. Release artifacts are still passed by path until RL0 packages them. - Links: remote install, roadmap S14.
D-081 The Claude Code item adapter reads Agent SDK messages, keeps no thinking text and is proven with synthetic fixtures (2026-10-03) — status: accepted
- Decision: S12c streams Claude Code Session items through the S12b
writer, lanes, bounds and reducer
(as built).
- The adapter reads the Agent SDK messages the executor already consumes,
with partial messages on: text deltas,
tool_useand itstool_result,api_retryand the turn'sresult. It skips subagent frames (workers have no agent tool) and an API error's stand-in answer, which the result reports as anerroritem instead. A whole block, sent when partial messages are off, takes its frame'suuidas key (Claude sends one frame per block under one message id), and none is reported once the turn has streamed. - Thinking: Claude may return its full thinking, depending on model and
settings, where the Codex adapter keeps only a visible summary. A
thinking block therefore becomes a
reasoningitem readingThinking…, with the text's length ascharactersin its extension, on the live and durable lanes alike; the text,signature,signature_deltaandredacted_thinkingnever enter a record (owner decision, 3 October 2026). - The prompt Maia sends is the turn's user
message, and each result adds ausageitem whose input counts cache reads and writes, as Codex's does; thinking tokens stay inside output, soreasoningOutputTokensis 0. - A call Maia's guard denies, or a
permission_deniedmessage names, completesdeclined. WithpermissionMode: "dontAsk"Claude asks nothing, so the adapter produces noapproval_request. - The item stream never affects a turn: the adapter drops a frame it cannot read, and the executor drops and logs a frame its own turn tracking cannot read instead of ending the message pump without settling the turn.
- The fixtures are synthetic JSON lines shaped after Agent SDK 0.3.285
(
services/node/src/harness/claude/testing/agent-sdk/), not a recording: by owner direction no test runs a real Claude turn.
- The adapter reads the Agent SDK messages the executor already consumes,
with partial messages on: text deltas,
- Why: the S12b vocabulary and writer already carry every bound, ordering and durability rule, so a Claude Session needs a mapping and no new mechanism; a raw thinking trace is not a summary a person chose to see; the owner's Claude usage is not spent on tests.
- Rejected: recording fixtures from the real
claudebinary (spends or risks a person's account); keeping thinking text live only (one more code path for text Maia should not show); starting tool items from the streamedinput_json_delta(the command or path is unknown until the input completes, and the completeassistantframe follows at once); requesting summarized thinking in the worker options (changes model behavior and cost). - Accepted gaps: a Bash item has no exit code (Claude reports none
apart from its output text); the command recorded is the one Claude asked
for, which can differ from what ran after Maia's guard rewrote the input;
a created file's diff is its content, not a unified diff;
usagecovers the main agent loop and no subagent or auxiliary call; a synthetic fixture can drift from what a futureclaudeemits, so an SDK upgrade re-checks the fixtures against its types. Records keep commands, output, diffs and paths unredacted, as D-077 accepted. - Consequences:
maia session attachstreams Claude Code Sessions, showing that Claude thought and how much, never what. With thinking displayomitteda turn has no reasoning item. Subagent frames wait for S39 child Sessions, and the Pi adapters for S23. - Links: roadmap S12, D-077, Claude Code executor.
D-082 Remove the third-party workspace importer; Claude Code and Codex discovery covers those sessions (2026-10-03) — status: accepted
- Decision: owner direction (2 Oct 2026). Maia drops the importer that
read another desktop client's state database: its preview and confirmation
operations, the
maia importsubcommands other thanscan, its discovery source and liveness hint, and the listing fields it fed (its source name, a per-session annotation, the unverified and several-sources counts). Claude Code and Codex discovery, history reads and adoption stay as D-078 describes. - Why: that client runs Claude Code and Codex sessions, which the vendors' own listings already return, so Maia finds and adopts them without reading a third party's private, unversioned schema. The owner also keeps the codebase free of references to that product.
- Rejected: keeping the importer behind an option (a second reader of a foreign schema for metadata the vendors already provide); rewriting migration 49 in place (every installed Node records its checksum).
- Consequences: a breaking protocol change on the unpublished 0.1.0
line. Migration 52 rebuilds
discovery_previewsso its kind admits adoption previews alone; open or confirmed previews of the retired kind are dropped, and their receipts, events and registered Projects stay; those receipts still list and show, butreceipt list --methodno longer filters by the retired methods. The single-valuedkindcolumn stays. Migration 49 keeps its original text, which still names the retired preview kind. - Links: D-078, CLI reference, roadmap S11.
D-083 Client features live in the SDK and the CLI: Session tasks, whole-Node lists, one error value (2026-10-03) — status: accepted
- Decision: owner direction (2 Oct 2026): every client feature exists in
@maiaos/sdkand the CLI before a client offers it.planSessionCreation,createSessionsandawaitSessionRunningturn task text into running Sessions with a first turn by composingworkspace.list,session.create,session.start,session.showandturn.send. Command IDs are minted once in the plan; a retry with the previous outcomes replays the unsettled items with the same IDs and request bodies. The CLI runs a batch once and reports each unknown item's command ID forreceipt show. The default Workspace (a Project's ready Workspaces, its main checkout first) is an SDK choice the Node still validates, not Node authority.session.listtakes noprojectIdfor the whole Node,statesandattentionfilters, and returns the Node's attention value of each listed Session, by the rule ofsession.observeand its default stall window. The Node owns that value; clients never derive it.classifyClientErrorreturns one taggedMaiaErrorwith akind; a Desktop confirmation the person declined staysdeclined, neveroutcome_unknown.watchNodeChangesruns over a platformStreamRelayand one cursor resume helper that later streams reuse.
- Why: the CLI cannot depend on client-core, so bulk creation, attention and error triage there left the CLI and agents without them and let clients diverge from the Node.
- Rejected: a Node batch operation with its own preview and
confirmation (the owner decided
turn.sendneeds none, so a batch is a sequence of idempotent operations, not a new consequential one); deriving attention in each client (a second source of truth); fanning outsession.listper Project (one bounded whole-Node page serves it). - Consequences: the CLI adds
session createwith--message,--prompt-stdinor--from-file,session list --allwith--stateand--attention, andsession start --wait, and prints attention insession listandsession observe. Its standalone grows by 5,104 bytes, so its ceiling rises from 320 to 328 KiB. client-core still holds its own creation, attention and per-Project fan-out until a follow-up switches it. - Links: SDK reference, CLI reference, coordination, roadmap S44.
D-084 Maia uses the installed Claude Code and Codex CLIs as they are (2026-10-03) — status: accepted
- Decision:
connection.detectreads, on the Node, each installed CLI's path, version and sign-in, andconnection.system.preview/confirm(maia setup) turns each usable one into a system-default Connection plus an approved executable throughconnection.createandintegration.apply.<vendor>. System default means the worker runs with the user's own vendor config: the login and the~/.codex/config.tomlit reads, such as a custommodel_providerwith its auth helper. Such a worker shares the account's (or proxy's) rate limits with other coding apps, Claude desktop and the CLI itself. Maia's containment still applies on top: the Codex permission profile, MCP servers disabled by name and approvals off, and for Claude no user settings, hooks, MCP servers or plugins (the SDK runs withsettingSources: []). A custom endpoint means Maia's workers use a model proxy and its account pool, so Maia's per-Connection usage and limit tracking may not reflect the proxy's own quotas. - Why: owner request (2 Oct 2026): "claude and codex cli are setup locally and on remote servers for other coding apps, maia should directly detect them and be able to use them directly as they are."
- Rejected: reading
auth.json,.credentials.json, the Keychain or any other credential to judge a sign-in (Maia parses the vendor's own status output and reads onlyenv.ANTHROPIC_BASE_URLand whetherapiKeyHelperexists insettings.json, andmodel_provider, itsbase_urland whetherauth.commandorenv_keyexists inconfig.toml); running a credential helper during detection; settingCLAUDE_CONFIG_DIR=~/.claude(Claude keys its Keychain entry by it); a client-side batch ofconnection.createandintegration.applythat the Desktop would have to allow one by one. - Consequences: the Node runs
--versionandclaude auth status --jsonorcodex login statusitself, outside the process resource manager like ssh (D-080), with the variables a Session inherits (HOME,LANG,LC_ALL,PATH,USER, no vendor root or key) and a 3-second bound each. Setup approves only a binary the Node's own PATH finds first, as approval resolves it. It skips a Claude CLI whose endpoint lives insettings.json, because Claude Sessions load no user settings, and a Codexenv_keyendpoint, because Sessions do not pass that variable; either needs a containment decision first. An approval counts only for the binary installed now (its SHA-256, cached by file identity), so a CLI that updated itself is approved again. A confirmation needs the preview's one-minute proof, actor, command and digest; it claims the proof once, re-detects, refuses any change to the previewed steps, and runs each CLI independently, approval before account, so a failure leaves no account that cannot run; setup then previews what remains. A first Codex turn on a developer Mac through a system-default Connection ran through its model-proxy provider. - Links: providers, vendor executors, connection protocol, roadmap S45.
D-085 The remote executor probe trusts the user's private group (2026-10-03) — status: accepted
- Decision: amends D-080's refusal of group-writable directories. The
probe accepts a directory or file that only its group can write besides
its owner when that group is the user's private group: named after the
user, the user's primary gid, listed once in
/etc/groupwith no other member, and no other/etc/passwdentry uses it as primary group. Every other group- or world-writable path still leaves the executor unavailable. - Why: owner direction (2 Oct 2026): use the installed CLIs as they are,
without asking the owner to
chmod. Ubuntu's user-private groups (umask 002) leave~/.local/binat 775, so the VPS reportedcodexandclaudeunavailable although only the owner can write there. - Rejected: trusting any group-writable directory; asking the owner to tighten the mode or pass an override.
- Consequences: a group the probe cannot read from the local files (a
directory-service group or user) fails closed. A member added later
through a directory service, or a group password used with
newgrp, is outside what the probe sees. - Links: remote install, D-080.
D-086 A coordinator is a Node principal, and only a person answers a worker's approval or question (2026-10-03) — status: accepted
- Decision:
maia mcpreaches the Node as the external-coordinator principal: it sends the operator's bearer withx-maia-principal: coordinator, and the Node admits the coordinator tool operations and refuses every other one withrole_deniedbefore any handler. Worker approvals and questions are one Node-owned resource,approval(approval.list,show,respond; tablesession_approvals). A coding worker's request (a Codex command, patch or question onapprovalPolicy: "on-request"; a Claude Code read or edit outside its worktree, or AskUserQuestion) waits for a person;respondbinds the decision to the digest of the exact text the person read and is human-only: the CLI at an interactive terminal or Desktop's native sheet. Expiry (10 minutes), a stop, a turn's end and a Node restart deny with a Node receipt. Reading workers never ask. - Why: a brain must never approve its own worker's request, and a decision must apply only to the request a person saw. One resource keeps one expiry, stop and resume path for every vendor and both kinds of wait.
- Rejected: worker questions as R1a interactions (those are record-only, actor-scoped and never wake a worker; making them resume a running turn changes their contract); a minted coordinator credential (on one machine the coordinator holds the operator's bearer anyway, so it adds storage without a stronger guarantee); auto-approving on errors or timeouts; a Project-wide "ask for everything" option before a user needs it.
- Consequences:
maia approve,denyandanswerrefuse to run without a terminal; a program driving a pseudo-terminal can still pass that check, so the guarantee against a brain with the operator's shell is the absence of any tool or principal path, not the terminal test. Codex network approvals are not mapped, so an approved network command can still fail. Migration 54 addssession_approvals. A Session holds at most 16 waiting requests; the Node refuses one more withapproval_pending_limit, which denies it. The CLI standalone ceiling rises to 332 KiB. - Links: vendor executors, CLI reference, roadmap S1 and S4b; Codex command and patch approvals amended by D-101.
D-087 Coding Sessions get the maia-worker Skill at launch without the skills CLI (2026-10-03) — status: accepted
- Decision: owner request (3 Oct 2026): every agent Session Maia
launches knows how to use the
maiaCLI. Before each launch of a coding Session the Node writes the reviewedmaia-workerSkill into the worktree's.agents/skills/maia-worker/, ignored through the repository's.git/info/exclude(Codex reads it there), and passes it to Claude Code as a Node-owned launch plugin (--plugin-dir-no-mcp) with theSkilltool limited to it. Migration 55 records what each launch injected (worktree.skillsinsession show);.maia/worktree.json"workerSkills": falseturns it off per Project. Reading Sessions, which have no shell, get none. - Why: Claude Code under the worker profile (
settingSources: []) reads noCLAUDE_CONFIG_DIR/skillsor.claude/skills, so only a launch plugin reaches it; Codex's sandbox cannot read a Skill in an account root under the home folder, so only the worktree copy is usable. Nothing touches the user's~/.claude,~/.codexor~/.agents; a realclaudethat loaded the plugin wrote no plugin or Skill into itsCLAUDE_CONFIG_DIR. - Rejected: running
npx skills@1.7.0at each launch (network or npm cache on every launch, no digest pin throughnpx, lock files written into the worktree and the home folder, a Claude target workers do not read; D-040 keeps it forconfig.apply); widening the Claude worker profile to project settings (it would load the Project's hooks and settings); writing into Maia-managed account roots (unreadable from the Codex sandbox, and absent for system-default Connections). - Consequences: on macOS no worker sandbox can run
maiaor reach the Node origin yet: a real Codex worker found the Skill, ran its command and was refused. Sandboxes stay as they are; workers will reach Maia throughmaia mcpas an MCP server carrying their Session credential (S18c, the next slice), and the Skill then points at its tools.session create --messageand--from-filealso start the child and send its first turn, which no Session role reaches, so the Skill tells workers to skip them. The Skill teaches only what a Session credential reaches today; the other worker Skills stay in S22. How Codex receives the Skill is amended by D-105. - Links: worker Skill at launch, D-040, D-074, D-075.
D-090 A slow launch never locks out a vendor executor, a crash fences it only where Maia cannot prove the tree ended, and a failure says why (2026-10-03) — status: accepted, amended by D-196
- Decision: a launch permit that never reached START settles
failedorexpired, nevercleanup_unknown, because its suspended process never ran. The permit start window widens from exactly 2 to at most 15 seconds (migration 56), and a later START fails that one launch with a retryabledeadline_exceeded. At startup a previous process's started permit settlesfailedonly where the kernel ends a dead host's target (Linux, Windows; read from the hosts' source, not yet observed), up to 8 seconds after startup for that host's grace and cleanup; on macOS it stays fenced ascleanup_unknown.integration removealso starts fromcleanup_unknown. A failed Session or turn carries afailure(a protocol error code and a message of at most 256 bytes Maia wrote) through storage, events, reads, the stream and the CLI. Maia gives up on a Codex turn at once on a structured credential refusal and 30 seconds after a retry notice without progress; the turn fails only if Codex confirms the interrupt within 10 seconds and isoutcome-unknownotherwise. A Codex that needs no OpenAI sign-in is never stored as signed out. - Why: dogfooding and the head-to-head benchmark found that one SIGKILL
mid-turn, or a launch slower than 2 seconds (each Codex launch hashes the
240 MB binary inside the window, twice per Session, so eight parallel
starts on a loaded host took longer), fenced the authorization as
cleanup_unknownwith no way out; every later Session failed withactivation_failedand no reason. A dead endpoint or a bad key left turns running for minutes while Codex reconnected. The benchmark's 30-secondturn waitstall came from its harness, which ran the CLI synchronously in the process that served the model endpoint; a wait holds nothing a turn needs. - Rejected: settling every started permit as failed after a crash (on macOS a host that died with its Node leaves the target running, and the Node keeps no pid or start time to check); a host protocol change to report them (later); caching the binary digest or moving the hash before the permit (it changes the vouch policy; the wider window is enough at today's caps); a free-text vendor error on the turn (it can echo a key); a fixed per-turn deadline (long turns are legitimate).
- Consequences: on macOS a Node killed mid-turn still needs a person:
check for a leftover vendor process, end it, then
integration remove,planandapply;removecannot end a tree from before the crash, because the restarted Node owns none. On Linux, processes a target started can outlive a host that died, while recovery records the tree as ended. The profile's instance lock keeps a second Node off the same database, so recovery never settles another live Node's permits. A provider that never answers and draws no Codex retry notice is not bounded by the 30-second rule;session observereports it stalled. - Links: vendor executors, Codex executor, CLI reference, roadmap S3, D-060, D-084; crash recovery amended by D-098.
D-092 Session lists stay within the worker credential's Project (2026-10-03) — status: accepted, amended by D-166
- Decision: both S18 Session roles (
workerandorchestrator) may callsession.list. The Node resolves the Project from the Session credential and supplies that Project to the existing list query before SQL pagination, state filtering and attention projection. An explicit different Project isrole_denied; an unresolved Project fails closed. Human clients retain whole-Node lists. - Why: workers need to discover Sessions in their own Project without learning about another Project. The existing credential already binds a Session through its Workspace to one Project; no new grant store is needed.
- Compatibility: the Session role table admits one existing read operation; request and response schemas, SDK methods and CLI grammar are unchanged. Older Nodes continue to refuse Session-authenticated lists. No migration.
- Follow-up: the external coordinator remains unchanged in this slice:
its operation allowlist still reaches whole-Node and explicit-Project lists
with the operator's bearer. It has no Project grants today. A separate
slice must define and bind its Project set when
maia mcpstarts (possibly from the current folder's Project), then enforce filtering before pagination and refuse ungranted explicit Projects. This decision does not grant a Session the coordinator's authority or claim coordinator Project isolation. - Links: worker credentials, roadmap S18 and S1, D-074, D-086.
D-095 Coding workers reach Maia through a Session-authenticated MCP launch (2026-10-03) — status: accepted
- Decision: Codex and Claude Code coding Sessions launch
maia mcp --workeras the reservedmaia_workerstdio server. The vendor starts the server outside its tool sandbox; filesystem and network policy stays unchanged. The worker Skill names MCP tools instead of blocked CLI calls. - Authority: reuse S18a's Session environment, never the owner's bearer.
The worker tool catalog derives exactly from
SESSION_ROLE_OPERATIONS.worker, including Project-scopedsession.list(D-092). Missing launch variables fail closed and the Node checks expiry, revocation and operation authorization on every call. No new operation or migration. - Launch: resolve the installed CLI from the Node's absolute PATH, refuse
worker-writable paths, and use a neutral cwd so development launchers do not
select a worker checkout. Codex receives command/args,
env_vars,enabled_toolsandrequired=true; a reserved-name collision refuses launch. Claude receives strict SDK MCP configuration and guarded exact tool names. Configurations contain no credential value; secrets remain in the Session launch environment. Discovery gets no credential. - Evidence: checked the Codex config schema and app-server notification schema, plus pinned Claude Agent SDK 0.3.285 declarations/serialization. Built CLI tests use real SQLite and loopback for allowed, disallowed, missing, expired and revoked cases; vendor fakes inspect launch arguments and guard behavior. Real model turns remain unverified for this slice.
- Consequences: an installed
maiaon the Node PATH is required for coding workers. The Project's Skill opt-out does not disable MCP. Reading workers retain their existing profile. See launch details.
D-097 An account's slot cap bounds live runtimes; unstarted Sessions only order the choice (2026-10-03) — status: accepted
- Decision: amends D-021. An account is full when the Sessions that hold
or are acquiring a runtime on it (queued, launching, recovering or ready)
reach its
maxConcurrentSessions, the same countsession startandsession resumeenforce. Unstarted Sessions from the last 10 minutes no longer fill an account: an account with an unreserved slot comes first, then the D-021 order. When unstarted Sessions reserve every slot, the new Session still binds to the best account that is not full. - Why: dogfooding hit
codex: full 4/4from Sessions that were created and never started, so a person could not create one more Session although no vendor process ran. The cap bounds live vendor capacity. - Rejected: dropping the reservation (a burst of creates would bind to one account and its starts would fail while another account sits free); counting every non-terminal Session.
- Consequences: a Session's account binding is permanent: Maia never
moves a created Session to another account (the binding row refuses
updates). Admission stays fail-closed at start: a Session bound to a full
account fails
session startassaturateduntil a slot frees or the cap is raised; to use a free account, create a new Session.session createrefuses only when every account is live-full or otherwise excluded. - Links: account selection, D-021.
D-098 A crash frees a vendor only when the recorded tree is observed gone (2026-10-03) — status: accepted, amended by D-196
- Decision: amends D-090's crash recovery. A started launch permit
records, with
startedand before START, where its process host keeps the tree (migration 57): the macOS host's process group, which the host leads and spawns its target into; the cgroup parent beside which the Linux host creates itsmaia-tree-*cgroups; or the Windows host's job. The bootstrap of the host in use writes it; a launch whose host can say nothing (a terminal, an unreadable Linux cgroup) records nothing. A restarted Node settles such a permit as failed once the record shows the tree ended: the macOS group is empty (kill(-group, 0)returnsESRCH), which the host allows only once it saw no tracked member survive; every Linux tree cgroup under the parent is gone or reportspopulated 0(a missing parent counts only where cgroupfs is mounted); a Windows job's grace has passed. It checks every 100 ms for up to that grace after its own start (5 seconds on macOS, 8 on Linux and Windows); a survivor, a check that cannot decide, or a missing or malformed record keeps the permitcleanup_unknown. The macOS host also notices its Node's death by its parent changing, not only by its control pipe closing. It then takes no more frames, ends the tree (SIGTERM, 1 second, then SIGKILL, repeated each second), treats pipe and poll errors as closed pipes, and exits once the group lists it alone and no tracked member lives; until then it stays alive, and its group with it. A failed group listing does not keep it forever, but a full member table or a live member whose children it could not list does: a descendant may then be untracked. - Why: after D-090 one SIGKILL of the Node mid-turn left Codex fenced on macOS until a person removed and re-approved it, although the host survives its Node and ends the tree, because the restarted Node kept nothing it could check. Settling by platform also trusted Linux without evidence from the host actually in use.
- Rejected: recording pids with start times (macOS gives TypeScript no process start time without a new native probe, and a list misses members that appear later, whereas the kernel keeps a group alive while any member lives and never hands its ID to a new process); a kqueue parent watch (a parent change is as decisive and needs no new descriptor; detection takes up to 100 ms); killing a survivor from the restarted Node (it owns no tree from before the crash); a host protocol change (the bootstrap already knows the host and its cgroup).
- Consequences: on macOS a Node killed mid-turn frees Codex at the next
start once its host ends the tree, usually at once. This is evidence, not
a proof, in the cooperative tier: the host samples descendants every
100 ms, so a descendant that double-forks and calls
setsidbetween two samples is never tracked and outlives a healthy host unseen, as it does the tier's normal cleanup; and if the host and its target both die abnormally (for example a person kills both), a tracked descendant that left the group escapes the check. A group whose ID came back to a new process keeps the permit fenced (safe, not free). The Linux check is not tied to the permit's own tree: any livemaia-tree-*under the same parent, such as another profile's Node in the same cgroup, keeps every started permit fenced; it rests on the hosts' source and fixture tests, and no run has killed a Linux Node. On Windows (unsupported in v1) the host requires kill-on-close and refuses a job that allows breakaway, and the target inherits only its three pipe handles, so the job ends every process the tree started with the host; a Windows Node alone relies on that. A process the tree had started through another service (WMI, Task Scheduler, COM) is outside the job. This rests on the source, not a run. A macOS host whose Node died and whose tree it cannot show ended keeps running until a person ends it;integration statusthen asks for both the vendor process and the Maia process host to be ended beforeremove,planandapply. Permits started by an older Node stay fenced after a crash. - Links: D-090, vendor executors, roadmap S3.
D-104 Worker MCP checks every hop of a PATH lookup and skips entries it cannot trust (2026-10-03) — status: accepted
- Decision: amends D-095's launch. The Node follows each PATH entry, and
each
maiaandnodein it, one link hop at a time and checks every entry passed: each hop's directory and every ancestor of the result up to/. An entry is replaceable when the worker can write its directory, or when the directory is world-writable, unless it is sticky, the Node's effective user or root owns it and the Node's user owns the entry; a link's owner is unknown, so a link there is replaceable. On macOS the worker's checkout and temporary directory match in any letter case and Unicode form, as the default volume does. A PATH entry the worker could redirect refuses launch; one another user could redirect is skipped and left out of the pinned PATH. A selectedmaia, or anodein a kept entry, refuses when anything on its way is replaceable or the file itself is world-writable. An entry the Node cannot search, or a link loop, is skipped like a missing entry unless someone else could create or replace it. The no-maia error leads with the install hint and names the first entry ormaialink Maia could not search; when a later entry suppliesmaia, a warning log lists the entries skipped ahead of it. Group-writable directories stay trusted. - Why: a world-writable file, a middle link hop or a world-writable parent let another user swap the CLI or interpreter while the old checks passed, and a single unreadable entry or link loop refused every coding Session.
- Rejected: refusing every entry with a world-writable ancestor: the stock
macOS PATH reaches
/System/Cryptexes/App/usr/binthrough the world-writable/System/Volumes/Preboot/Cryptexes, whose entries System Integrity Protection guards with flags stat cannot see. A skipped entry supplies nothing, so skipping keeps the guarantee. Reading file flags or link owners would need a native probe. - Consequences: the pinned PATH omits such system entries; where one
precedes the CLI, as Cryptexes does on stock macOS, each launch logs that
warning. A
maiaornodereached through a link in a sticky directory such as/tmprefuses even when the Node's user owns the link. - Links: D-095, launch details.
D-088 Desktop reaches registered remote Nodes over the SSH stdio bridge (2026-10-03) — status: accepted
- Decision: owner direction (3 Oct 2026): one Desktop window manages this
computer's Node and every Node registered with
maia node add(D-080).node_requesttakes an optional target,localorremote:<name>. Rust resolves the name through the local Node'snode.remote.list(now exposed to Desktop as a read), runs/usr/bin/sshwith the SDK's options and bridge command, the same asmaia --node, and speaks the protocol's bridge frames. The reviewed allowlist, bounds and native sheet apply per request; the sheet names the target Node, and an approved request is sent only if the registration it showed is unchanged. The cockpit lists every Node's Sessions with a Node label and filter, and New Session picks the Node first. - Why: the shortest path to one window over local and remote Nodes that
reuses one transport, one registry and one authority: no network listener,
and Desktop stores no remote credential, because
maia-node bridgeon the host adds that Node's own bearer. - Rejected: a renderer-supplied host or command; Desktop storing remote
bearers or opening a tailnet connection now (device pairing over the
tailnet stays the later path, for mobile first); an SSH library in Rust (a
new crate for what the user's own
sshand configuration already do); Desktop runningmaia node add(the CLI owns installs; the cockpit shows the command). - Consequences: one ssh process per remote Node while it is used: it must
greet as the registered profile within 30 seconds, closes after 5 idle
minutes, is not reopened within 5 seconds of a failure and stops with
Desktop. Each Node has its own bound of four active requests (sixteen
overall), and Desktop reads the registry only after the local Node proves its
identity against its private descriptor. The bridge
carries no observation stream yet, so the cockpit reloads a remote Node
every 15 seconds. A failure reaches the renderer as
node_connectingornode_unreachablewith ssh's last line, which the cockpit shows; a registered Node that is not ready is listed as unreachable. The Rust copy of the SDK's ssh arguments is pinned by a test against the SDK's output. - Links: Desktop README, client architecture, roadmap S14.
D-101 A Codex coding worker asks only questions, and its sandbox lets node and git work (2026-10-03) — status: accepted
- Decision: amends D-086 and D-075 for Codex coding Sessions. A worker
whose questions the Node holds launches with
default_mode_request_user_inputon, so its blocking question tool reaches the approvals queue outside Plan mode, and with a granular approval policy (a-coverride) that refuses escalations, patches outside the worktree, rule prompts, permission requests and MCP elicitations; Maia holdsitem/tool/requestUserInputalone and declines any other request. Themaia-writeprofile grants, for reading, macOS's/System/Library/OpenSSL/openssl.cnf, a Node-owned Git configuration (<node state>/harness/codex/<session-id>/gitconfig) and the Git metadata status, diff and log need (the worktree's Git directory and the common directory'sHEAD,config,info,objects,packed-refs,refsandshallow), and, for writing, a Session-only cache folder beside it that the Node empties at each launch, removes when the runtime ends and never reads (the macOSxcruncache).GIT_CONFIG_GLOBAL,GIT_CONFIG_NOSYSTEMandxcrun_dbreach the worker's commands throughshell_environment_policy.set, never the app-server's environment. The Node withholds the Git grant when the repository's local or worktree configuration (includes resolved) holds a URL with a password, HTTP user name or secret query parameter, anhttp.extraHeader, a credential helper other than a bare name or a setting named for a secret, or cannot be read. - Why: the swarm smoke test found Codex 0.160 workers unable to ask
(outside Plan mode they fell back to a non-blocking question nobody saw),
unable to start
node(OpenSSL configuration denied) or rungit status(owner's~/.gitconfig, the sharedxcruncache and the Git metadata all denied), and a person's approval of an escalation changed nothing: a real run showed Codex runs an approved escalation with every path the profile denies still denied, reads and writes alike, so an approval record promised what the sandbox never gives. - Rejected: mapping the non-blocking question (it arrives as an agent
message, the turn ends, and the answer would have to start a new turn,
changing D-086's wait); keeping escalation approvals (a no-op a person
cannot tell from a grant); removing the profile's root denial for
escalated commands (Codex offers no per-command override, and it would end
the containment D-075 relies on); exposing the owner's
~/.gitconfig; the whole.git(hooks, logs, submodule and sibling worktree configurations would be readable and unchecked); writing the cache into the worktree (it would reach the branch) or the shared per-user temporary folder; the tool variables in the app-server's environment (Codex's own Git, outside the profile, would read the worker-writablexcruncache, which names the binary a shim runs); a granular policy onthread/start(Codex accepts it there from experimental-API clients alone). - Consequences: a coding worker can no longer get a command approved; a
command its profile denies fails, and Codex tells the worker it cannot
escalate. Write access outside the worktree is the cache folder alone, read
by the Session's own sandboxed commands. Workers read every object of
their Project, including other coding Sessions' uncommitted files the Node
staged for their diffs. The credential check is a denylist over the
configuration, run once per launch. Codex keeps a thread's own approval
policy when it resumes, so a thread started before this keeps
on-request, and Maia declines its command and patch requests with a JSON-RPC error (not observed against a real resume). Whether a user's Codexallowrules can still run a command unsandboxed under this policy is not verified. Claude Code workers are unchanged. - Amendment (2026-10-03): a real upgrade (a Session started on a build
before this decision, stopped, then resumed on one after it over the same
migrated state) showed the resumed thread keeps
on-requestwhile themaia-writeprofile re-applies, Git grants included. When the model asked to escalate a command, Codex logged Maia's JSON-RPC error ("Declined by Maia worker policy.") as a denied approval; the worker reported the refusal, the turn completed, the Session kept running and the approvals queue stayed empty. A patch request on such a thread was not observed, and userallowrules remain untested. - Links: coding Sessions, approvals and worker questions, D-075, D-086.
D-103 One command tree describes every CLI command, and help answers on every node (2026-10-03) — status: accepted
- Decision: owner-approved CLI reorganization, slice 1 (paths unchanged).
Each command is authored once, as a leaf row of a feature fragment in
apps/cli/src/command-tree/features/: its usage, availability, operations, a declared kind (operation,discriminant,two-phase,compositeorlocal), a summary and an optional example. The tree replaces the help rows, the test inventory's usage strings and the families' usage literals.-hand--helpwork on every node andmaia help <command>...names one; human root help is a section index.maia help --jsondescribes the whole tree (summary, kind, availability, usage, example, operations) with a versionedcontractblock; a command's--help --jsonadds typed arguments read from its usage. A usage error (exit 64) adds the nearest command'susageand ahint.--node,--profileand-h/--helpare global anywhere before--; a command that takes its own--profilekeeps it until slice 2 renames those flags. Registry parity reads the fragments and drops its usage-count and digest pins (schema v3); the typed tree test owns them: every leaf names CLI-callable operations or declares its kind, every callable operation has a leaf, a shared operation only under declared kinds, availability equals the inventory classification, every root command is dispatched and sectioned, and one grammar digest. The protocol descriptors'projections.cliusage strings stay, checked against the tree, until slice 2 makes them booleans. - Why: each usage existed four times, help was flat (19.8 KB at the root, no help on leaves) and an LLM operator needs one machine-readable description of the commands and of the stable output contract.
- Rejected: a generated help snapshot (another pin to re-measure); typed arguments in the whole-tree JSON (94 KB against 60 KB); dispatching from the tree now (slice 2, with the folder move); loading help from a separate chunk to save bytes (the standalone executable is one compressed bundle, so a lazy import saves start time, not bytes).
- Consequences: amends the CLI rule against a default full usage dump:
the JSON root lists every command, without typed arguments, within a
64 KiB budget; one node's help stays within 2,560 bytes and the human root
within 3,200. The help JSON shape changed (family pages became node
pages); command paths, arguments and exit codes did not: all 165 usage
lines of the old help registry appear verbatim in the tree, which adds
only
help [<command>...](compared once against main). The grammar digest changed with that line and the tree's order. Usage-error messages are shorter, since the usage follows them. Summaries and help add 5.6 KB to the standalone executable after the family literals are removed, so its ceiling rises from 332 to 338 KiB. - Links: CLI reference.
D-105 Codex workers drop the prompt sections they cannot use and get the worker Skill as developer instructions (2026-10-03) — status: accepted
-
Decision: amends D-087 for Codex. Every Codex worker launches with
skills.include_instructions=false,skills.bundled.enabled=falseandagents.enabled=false. A coding worker whose launch injected the exact reviewedmaia-workerSkill also gets its body, without the front matter, as-c developer_instructions=...; the worktree copy andworktree.skillsstay as D-087 describes, and Claude Code still loads the Skill as a launch plugin. The Skill no longer tells a worker to callcontext_showfirst; the tool stays available. -
Why: Codex re-sends its whole prompt prefix on every model call, and a turn's
inputTokenssums every call. The Skills list carried the user's own Skills (about 20,000 characters for the owner), which the worker profile denies reading anyway; the multi-agent messages stayed although--disable multi_agentremoves the tools; bundled system Skills have no worker use; and the forcedcontext_showadded a model call to every first turn.codex debug prompt-input(Codex 0.160.0, no model call, Maia's exact coding-worker flags) measured the model-visible input of a one-line task: 29,152 to 6,368 characters (about 7,300 to 1,600 tokens) with the owner's Skills and globalAGENTS.md, 7,471 to 3,807 characters (about 1,900 to 950 tokens) with an empty home. A real Codex coding turn (read, edit, runnode, answer) used 31,078 input tokens (20,224 cached), where the swarm smoke test's tiny turns used about 132,000 to 165,000; the live rollout carried the worker instructions and no Skills list or multi-agent messages. Each key was checked against Codex 0.160.0: the skills keys change the render and are fields of its skills configuration, andagents.enabledparses as a field where an unknownagentskey fails. -
Rejected:
include_permissions_instructions=false(the approval wording is how a worker learns it may ask a person); replacing the base instructions withmodel_instructions_file(vendor-tuned tool behaviour); a separate short instruction text beside the Skill (two sources for one behaviour); trimming the worker MCP schemas (under 1,000 tokens). -
Consequences: a Codex worker no longer sees any Skill listing, so Skills a Project commits under
.agents/skillsor.codex/skillsare not offered to it; it can still read those files in its worktree. The worktree copy ofmaia-workeris no longer listed to Codex either; it stays as the record the instructions follow. Codex 0.160 ignores unknownskillskeys, so a renamed key would silently bring the list back. A coding worker'sdeveloper_instructionsreplace any the user configured for Codex. Reading workers get the same prompt cuts and no instructions. Whether a resumed thread keeps the first launch's developer instructions was not observed. -
Amendment (2026-10-03): Codex fixes
developer_instructionsat a thread's first turn. Two-turn real probes, where the first and the resumed launch each passed instructions A, B or none:First launch Resumed launch The thread follows A B A A none A none B none So a worker Skill change reaches new threads alone, and a thread started before this decision keeps the Skills list and the multi-agent text until it is archived and recreated. Because Codex ignores unknown
skillskeys,connection detectnow renders the installed Codex's prompt withcodex debug prompt-input(no model call, network or sign-in), with and without the three keys, in temporary homes holding one sentinel Skill and never the user's~/.codex, and reportsworkerPrompt(ok,failed,inconclusive);maia doctorshows it ascodex-cli.worker-promptand fails it asskills.* keys no longer honored by Codex <version>. A misspelledagentskey makes Codex exit 1, which the check also fails. -
Links: worker Skill at launch, coding Sessions, D-087, D-101.
D-089 Composer dictation is on-device platform input with no Node operation (2026-10-03) — status: accepted
- Decision: the Desktop composer and task dock offer dictation through
macOS
SFSpeechRecognizerwithrequiresOnDeviceRecognition. Three native commands (dictation_start,dictation_finish,dictation_cancel) serve the trusted main window. The bridge admits one session at a time and the localesauto,en-USandfr-FR. It sends a level, partial and final text of at most 4 KiB on a Channel, and listening ends 2 s after the last new words or at 60 s. Recognized text enters the draft at the caret, as typing does, and leaves only through the existing send (turn.send, or New Session's confirmation). Client-core owns the presentation state. Other platforms report dictation as unavailable. - Why: the owner asked for a ChatGPT-style dictation button. The October 2026 spike measured the first partial at about 550 ms and the final at about 50 ms after the audio, exact in English and French, with no account and no network. A dictated draft is reviewed before sending, like a typed one, so it needs no Node authority.
- Rejected: OpenAI transcription (needs a Platform API key, not a
ChatGPT sign-in); Codex app-server realtime (experimental, a transcript
only at the end of speech, and it starts Codex turns without Maia's
confirmation); a Node
voice.transcribeoperation (it would move audio into the Node for no gain); a server fallback when the local model is missing. - Consequences: no audio is written or sent, and nothing is persisted
but the draft text. The language choice resets when the cockpit
remounts. macOS checks the launching app's Info.plist, so dictation works
only from the bundled
.app;pnpm dev:desktopfrom a terminal cannot dictate. The CLI gets no dictation command. The realtime voice bridge is unchanged. - Links: Desktop README, roadmap S46.
D-102 One coordinator exposure field on feature descriptors (2026-10-03) — status: accepted
- Decision: a feature operation descriptor has one exposure field for
external coordinators,
projections.coordinator:false, or the MCP tool's description with explicitreadOnlyanddestructivehints. It replaces the unusedagentfield (every descriptor hadagent: false); there is no separate MCP switch. Each descriptor lives beside its protocol scope (<scope>/feature.ts) and the registry holds one feature list. - Why: the coordinator tool list is also the coordinator principal's whole authorization allowlist, so it is one fact about an operation. Keeping it on the descriptor puts exposure next to the operation's risk, receipt and projection policy instead of in a parallel hand-written list.
- Rejected: keeping
agentbeside a newmcpflag (two switches for one grant); deriving exposure from risk orLOCAL_NODE_METHOD_EXPOSURE, which classifies Maia agent requests, not the operator's own coordinator. - Consequences:
MaiaFeatureExposurePolicychanges shape and the feature-registry entry exportsMaiaFeatureCoordinatorTool. Registry parity parses the field and fails closed when it is missing. The 40 reviewed tools were seeded into descriptors (15 operations gained a descriptor), andcoordinatorToolDefinitions()andCOORDINATOR_TOOL_OPERATIONSare derived from them: the same operations, names, descriptions, input schemas and hints, now in registry order instead of the hand-written order. A test pins the reviewed allowlist with its hints; the worker projection is unchanged. The same descriptors carrypolicy.ownReceipt, whether an operation records a receipt under its own method; thereceipt.listfilter is derived from it plus a residual inventory of undescribed methods, composed in local-node, and equals the reviewed 79-method filter (94 others). Node dispatch sends a described request through the feature handler table, whose adapters keep the command ID and attached credential, the adopted-Session resume preflight,terminal.create's mode routing and optional-service checks; the SDK checks descriptor bindings instead of copying them. Deriving at runtime puts every descriptor wherever the request schema goes: the CLI standalone grows by 5,084 bytes (pack +4,152) and the web renderer by 66,556 bytes (11,186 gzip). One derivation is preferred to a second, hand-kept filter list; a test-pinned static list in local-node is the fallback if a client budget needs the bytes back. - Links: coordinator tools, D-095; the receipt filter and the SDK typed call lists amended by D-113.
D-091 Routed pages in one app shell; the URL records where a person is (2026-10-03) — status: accepted
- Decision: owner direction on 3 October 2026 ("start with correct layout
foundation / pagination / routing system"), replacing the layout of
D-053 and the panel placement of D-079:
- the Desktop renderer is file-based expo-router pages: Home
/, a Workspace/w/[id], a Project/p/[id], a Session/s/[id],/attention,/sessions,/nodes,/start,/settingsand/voice. Search, state filter and page are query parameters (?q=&state=&page=), so back, forward and deep links replay a place; - one AppShell from the Pencil core frames (scope switcher or back arrow and title, search and settings, quick navigation chips, the command dock) and three templates: grouped list, list-detail and detail;
- one cockpit mounts above every route, so moving between pages reloads nothing; overlays (launcher, New Session, stop) and the keyboard stay with it;
- a boot gate mounts the routes only after the Desktop host reports a ready
Node (
node_lifecycle_status, one automaticnode_lifecycle_start, then status reads with a growing pause), and offers one "Start Maia" action when it cannot; - one pagination model: a page holds the rows that fit the window, over the
cockpit index the Node's
session.listcursors load.
- the Desktop renderer is file-based expo-router pages: Home
- Why: the owner's Pencil design is page-based; a chat column beside a work panel left the cockpit cramped and its state invisible to back, forward and links.
- Rejected: keeping view state in a reducer (no history or deep links); one cockpit per page (reloads the portfolio on every move); per-page pagination components.
- Consequences: the coordinator chat shell, its sidebar, inspector and
demo automations are deleted; voice moves to
/voice; recorded questions open from the Session page header. The client's json-render renderer for the three portfolio lists (D-052) is removed with the sidebar that mounted it; the Node and SDK presentation catalog stay, and routed pages render the cockpit index directly until a page needs the catalog. The index pages the whole-Nodesession.list(D-083) and pages slice it in the client; the attention list orders the Node's own reasons. The client-core presentation module (Workspace directory and list views) now has no client consumer apart from itsNodeChangesservice. The Session page mounts the live Session view (D-077) and puts its composer, with dictation (D-089), in the shell's dock;/nodeslists the remote Nodes (D-088) and narrows every page to one Node or shows them combined. - Links: UI, client README, client-core.
D-106 Scope-first CLI paths, two-phase leaves and agent-friendly conventions, dispatched from the command tree (2026-10-03) — status: accepted
- Decision: owner-approved CLI reorganization, slice 2, as a clean break
(Maia is unreleased, so no aliases). Paths are scope-first, noun then
verb, with one closed verb set: turns, approvals, questions and
interactions live under
maia session …, relocation undermaia execution relocation …, the Node's own status undermaia node status(with a top-levelstatusshortcut), remote installs undermaia node install, and every list or show leaf names its scope with a flag (--session,--project,--run,--workspace,--terminal) and takes at most one positional reference. Each preview and confirm pair is one two-phase leaf: at a terminal it previews and asks y/N; a script previews with--dry-runand pipes that preview to--confirm-stdin; without a terminal and without a flag it exits 64 naming the flags (a leaf that reads its request from--input-stdinnever prompts, so it always needs one of them), and--yesstays onsetup,node installandnode remove. A mistyped or retired path prints "Did you mean 'maia …'?" and never runs. Global options go anywhere before--, and every token after--belongs to the command,--jsonincluded.MAIA_PROFILE,MAIA_NODEandMAIA_NO_INPUTstand in for--profile,--nodeand--no-inputon the leaves that take them; a flag wins.-vis gone;--timeoutand--stall-aftertake durations (30s,10m);import scanpages with--after; lists default to the protocol's 20 items unless their usage shows another range;doctorreplacesrecovery;completion bash|zsh|fishis generated from the tree;version --jsonadds the protocol version and, in a build made withMAIA_CLI_BUILD_COMMIT, the commit. Each command-tree fragment lives in its feature'stree/scope, each leaf names a lazily loaded runner and owns its human formatter, and the CLI dispatches from the tree, so the hand-kept command lists and the dev isolation regex are gone. The protocol descriptors'projections.clibecomes a boolean; registry parity joins it to the tree and checks the grammar policy (a revision guard needs an--expected-*flag, a command ID needs--command-id). Asaturatedanswer is retried four times with short jittered backoff and the same request body.session turn waitexits 70 when a waited turn failed. - Why: paths grew one family at a time and mixed nouns, verbs and positional orders; a two-step command pair was two commands to learn and could not prompt; agents need one non-interactive rule, durations, stable exit codes and suggestions instead of guessing.
- Rejected: aliases for the old paths (a second grammar to keep and
test);
--yeson every two-phase leaf (a person should confirm a hold, a guard or an agent permission they saw); a client-side dry run (a second authority); borderless table headers for lists (deferred: every list formatter changes again with Session titles in slice 3). - Consequences: every Skill, guidance copy, fixture, Node recovery
message and document uses the new paths; the worker Skill names MCP tools
only, so its digest is unchanged. The orchestration guidance digest, and
with it the voice system prompt digest, changed. The standalone
executable grows by 5,320 bytes for the leaf formatters, two-phase
prompts, doctor, completion and suggestions, so its ceiling rises from
345 to 348 KiB. The protocol does not yet report a turn's model-call
count; the Node would need to add it to turn token usage before
session turn showcan print it. - Links: CLI reference,
D-103; the
saturatedretry and the model-call count amended by D-108.
D-107 The Node resolves names, ID prefixes and the current folder; Sessions have titles (2026-10-03) — status: accepted
- Decision: owner-approved CLI reorganization, slice 3, built on the
feature model (M3).
reference.resolve(read, coordinator tool) answers from one Node read: the exact ID wins; otherwise ID prefix matches (at least 4 hexadecimal digits, with or without the kind tag) and exact name or Session title matches count together, then a name that differs in letter case. One match resolves; more answer up to eight candidates; none failsresource_not_foundnaming the kind. Kinds: Session, Project, Workspace, Connection, Container; a Session or Workspace may be narrowed to a Project.- Without a reference it resolves the default Project or Workspace: the
deepest registered Workspace checkout or Project repository root that
contains the caller's
path(the Node resolves symbolic links; no Git process runs), else the selected Workspace. A Project found by its repository resolves to its one Workspace, else the selected one if it belongs to it, else the candidates. - The CLI resolves every reference its usage names (positionals and scope
options) before the leaf runs, Projects first, and sends exact IDs, so a
mutation binds the resource the person named and a preview shows it. A
value tagged with its kind is sent as an ID (only a tagged prefix is
resolved), so a Session credential never needs the lookup. Ambiguity
exits 64 listing the candidates. A missing required
--projector--workspacecomes from the current folder (sent only to the local Node), then the selected Workspace, else exits 64 naming the flags. JSON success gainsresolvedand human output one line per resolution on stderr.maia reference resolveshows an answer directly. Only a full ID (<kind>_and 32 hexadecimal digits) skips the lookup;--expected-*guards are never resolved; one command resolves at most 32 distinct references and checks each answer is of the kind asked for. - Sessions gain an optional title (one line, 1 to 80 characters, no
control or format characters):
session.createtakes one; otherwise the Node stores the first line of the Session's first turn.session.rename(reversible, command ID, revision guard, own receipt,session.renamedevent, coordinator tool, not Desktop) changes it in any state, an archived or held Session included, since a title is metadata; it advances the Session's revision like any other Session mutation, so a turn sent at the old revision fails its compare-and-set. Migration 58 addssessions.title. - The SDK's typed
call(method, parameters, options)serves every operation whose descriptor bindssdk.operation: "call": parameters and results are typed from the protocol unions, the payload is validated before sending, the answer must name the method and, for a command, echo the command ID in its receipt (an unconfirmed command reportsoutcome_unknown). The CLI has onecalldependency andmaia mcpderives the call-bound coordinator tools, so a call-bound operation needs no SDK method, CLI dependency or MCP entry of its own. - List leaves print plain header tables (a header row, columns aligned by two spaces, no borders, then the next cursor).
- Why: owner decisions of 3 October 2026: Sessions get titles that work as references, and the current folder's repository is the default Project through a Node operation. Proving a name unique needs the whole set, which only the Node holds in one authorized query; the CLI's own connection name search stopped after ten pages.
- Rejected: resolving in the client (a second authority, and bounded
pages miss duplicates); running Git on the caller's folder (registered
roots are already stored, and external worktrees can wait); deriving a
title at read time (no column to match or index); preview and
confirmation for a rename (a reversible title change; compare-and-set with
a receipt suffices);
--schemain this slice (the bundled schemas do not fit the size budget; it stays planned for R1); a worker-credential default (workers reach Maia throughmaia mcp). - Consequences: two operations join the protocol (the reference scope
adds four declaration files to the package entry and the feature
registry); the coordinator allowlist grows by
reference.resolveandsession.rename(43 tools); the receipt filter bysession.rename(80 methods, 96 others). Sessions created before migration 58 stay untitled until renamed. A default title is the first line of the person's or coordinator's first prompt, so the Session's title is visible wherever the Session is (lists, sibling workers in the same Project); this is the same owner's data and accepted. The typed call also checks a command's receipt targets the resource its request named. The resolver, typed call, two leaves and list tables add 6,252 bytes to the standalone executable, so its ceiling rises from 348 to 355 KiB. A command without its required scope now asks the Node for a default, so it exits 69 instead of 64 when no Node runs. Approval, question, terminal, model and inbox reads keep their text formats. A cwd inside a worktree Maia does not know (an externalgit worktree add) has no default yet. - Links: CLI reference, D-106, D-102.
D-108 A saturated refusal names what is full, and turns report their model calls (2026-10-03) — status: accepted
- Decision: amends D-106. A
saturatedprotocol error may carrysaturation:admissionwhen the Node's request, connection or Session request slots were full, oraccountwhen an account's Session slots, its vendor limit or the provider pool refused it. Every Node error the API answers with, and a bridge failure frame, keeps it. The CLI retries onlyadmission, and a client transport that was full; anaccountrefusal, or one that names nothing, exits 75 at once. A completed turn records its token usage with an optionalmodelCallCount: Codex counts one call per raised thread token total in itsthread/tokenUsage/updatednotifications from the turn Maia started, Claude Code reports its result'snum_turns(the SDK's model round-trips in the turn); the Session's usage item carries the same count. The setup preview repeats the requestedmaxConcurrentSessionsand each existing Connection's cap. A running Session whose latest turn failed or ended with an unknown outcome reportsturn_failedattention until a later turn is sent or the Session stops; a turn a stop ended raises nothing, so a resumed Session starts clean.turn_failedranks afterquestionand beforestalledandsettled_unreviewed. - Why: the CLI retried an account that stays full for minutes with the same backoff as a busy Node; a turn's cost was unknown without its model calls (vendor executors also never recorded a turn's tokens); the CLI copied the previewed cap into its dry-run itself; a failed turn in a Session that kept running needed no person by any attention rule.
- Rejected: a new error code for account limits (
saturatedis the documented code for a full account, D-097); counting every usage notification (Codex repeats the last total when its rate limits change); inventing a count where the runtime reports none. - Consequences: strict readers of the error, turn, usage item, setup
preview and attention schemas need the new fields and value, so Node and
clients update together. The first Codex notification after a Node restart
cannot be told from a repeat, so a resumed thread may count one call, and
that call's tokens, too many on its first turn. Other
saturatedrefusals (a runtime still stopping, a hold, a full guard preview store, a Pi RPC refusal whose cause Maia cannot tell) name nothing and are no longer retried by the CLI; their messages already ask the caller to retry, and the CLI exits 75. - Links: CLI reference, protocol, D-097, D-106.
D-109 One paged read returns the Workspace to Project index (2026-10-03) — status: accepted
- Decision:
container.index.listreturns every organizational Workspace with its Projects and each Workspace's Project count, in pages of up to 500 rows (a row is a Project, or a Workspace with none); a Workspace a page cuts continues first on the next page. Each Project carries its ID, name, revision and, when it has one, the root path of its first repository, so a person or a coordinator can tell which checkout a Project is (the CLI prints it; the Desktop index ignores it). The Node serves a page with one indexed query, plus aggregate reads for the cursor snapshot, and signs the cursor per actor and snapshot of Workspaces, Projects and repositories. The read is exposed to the CLI (maia container index list), the reviewed Desktop renderer and the external coordinator as a read-only tool. The Desktop cockpit loads its Workspace and Project index through it instead ofcontainer.listplus onecontainer.showper Workspace, and its limits rise from 100 Workspaces and 400 Projects to 1,000 and 2,000. - Why: migration 35 gives every Project its own Workspace, so a Node with more than 100 Projects showed only part of itself, and 500 Workspaces cost about 505 requests. Counting rows bounds a page even when one Workspace groups thousands of Projects.
- Rejected: raising the per-Workspace read cap (still one request per Workspace); a page bounded by Workspaces only (unbounded Projects per page); cursors that survive changes between pages (a page could skip a Project added to an earlier Workspace).
- Consequences: this is the first read that returns a registered
Project's repository root path (
discovery.sessions.listalready returns discovered ones);project.showstill omits it. The coordinator allowlist grows to 41 tools and the Desktop allowlist to 73 operations. A change to any Workspace, Project or repository between two pages fails the next page withstorage_conflict; the Desktop cockpit restarts the index read up to twice, then reports the error.container.listandcontainer.showstay for the CLI, the presentation reads and the client-core portfolio module. A Node with 64 or more running Sessions still shows an unnamed partial notice because of the separatesession.observeruntime cap. - Links: Workspace hierarchy, client architecture, D-012, D-102, D-106.
D-110 A Codex tree on Linux runs its own sandbox, and its profile carries Maia's read denials (2026-10-03) — status: accepted, amended by D-184
- Decision: amends D-019 and S18a for Codex Session trees. The Linux
process host takes a
vendor-sandboxfilesystem mode (MAIA_PROCESS_HOST_FILESYSTEM), which the process manager selects for acodex-sessionlaunch of the approved Codex binary (its launch carries the Codex authorization): the tree's Landlock domain handles no filesystem right and keeps the signal and abstract socket scoping; its cgroup, limits,cgroup.kill, seccomp filter,no_new_privsand the once-a-second containment check are unchanged. The mode refuses read denials (filesystem_mode_invalid). The Node writes the runtime directory and the Maia auth file into Codex'smaia-readandmaia-writeprofiles asdenyentries on every platform, each as named and with existing links resolved (its deepest existing ancestor), which hold even beneath a granted folder, and on Linux grants the profile read access to Codex's own program (the@openaiscope folder of the npm package, or the approved binary). Claude Code and other trees keep Maia's Landlock filesystem rules (D-184 amends this for coding Claude Code trees). - Why: the remote swarm smoke found every Codex Session on a Linux Node
failing at
thread/start: Codex confines its commands with bubblewrap, which makes the root mount a slave in a new mount namespace, and Landlock refuses any mount change inside a domain that handles filesystem access (bwrap: Failed to make / slave). A domain with only scoping lets bubblewrap run, inside the helper's seccomp filter and cgroup too. Codex 0.157 then runs its program again inside bubblewrap and does not add it to a profile that denies the root, so commands failed until the profile granted it. - Rejected: Codex's
use_legacy_landlockfeature (0.157 refuses it for any profile with restricted reads: "filesystem-restricted execution requires bubblewrap"); dropping Codex's restricted reads on Linux (a worker could read every file of the user's, which Maia's Landlock never stopped); a mount namespace of the helper's own to keep/sysread-only (needs user namespaces with capabilities, which Ubuntu's AppArmor withholds from unconfined programs); refusing Codex on Linux, the interim guard this replaces. - Consequences: inside a Codex tree, Maia no longer enforces the
/syswrite denial or the read denials itself. Codex's sandbox does, for every command the model runs (no/sys, no network, reads and writes as the profile grants), and that is where a model-driven escape would start; the app-server process, the worker MCP server it starts, Codex's built-in tools that run in the app-server rather than in bubblewrap, and anything Codex runs outside its sandbox are trusted as the approved vendor binary, as in macOS's cooperative tier. Such a process can write anything under the delegated cgroup parent its user owns: itsmemory.maxandpids.max(the containment check then fails the run within a second,containment_lost, which thevendor-limitshost check pins), its other settings such asmemory.swap.max,memory.oom.groupandcpu.weight(unseen), its descendants' membership (a process moved to a sibling cgroup escapescgroup.kill, and thelinux_cgroup_emptycleanup proof then reports a clean tree while it runs, so that proof is best effort for a Codex tree), and other trees' limits,cgroup.killandcgroup.procsor the parent'scgroup.subtree_control, so it could stop or loosen other Sessions on the same Node. On the VPS (Linux 6.17, Codex 0.157, a release with this change) a Codex coding Session started, ran a shell command that wrote and read a file in its worktree through bubblewrap, and completed its turn (2 model calls, about 20,000 tokens); archiving it removed its tree cgroup. On Linux, Codex 0.157 kept adenyentry beneath the worktree for a file created, with its parent folder, after the app-server started unreadable and unwritable to its commands (observed withcommand/exec, no model call, under a profile shaped likemaia-writewithout its Git grants). Whether a user's Codexallowrules run a command outside the sandbox (D-101) now matters on Linux too; it is still unverified. Claude Code on Linux uses bubblewrap for its Bash sandbox as well; D-184 covers its coding trees. The Linux host checks add bubblewrap runs in both modes (refused by default, running invendor-sandbox; skipped where/usr/bin/bwrapis missing), the scoping avendor-sandboxtree keeps, a raised limit failing the run, and the refused mode combinations; the macOS decoy qualification adds a decoy auth file beneath the worktree that only thedenyentry keeps unreadable and unwritable. - Links: Linux process host, worker credential, D-019, D-101.
D-113 Request-schema clients get checked static lists instead of the feature registry (2026-10-03) — status: accepted
- Decision: amends D-102. The
receipt.listfilter inlocal-node/receipt-list.tsis one static, sorted list of the 80 methods that record a receipt under their own method, and the SDK typed call keeps one static table of each call-bound method and its idempotency, in registry order. Their types are read from the descriptors, so the type checker rejects a missing, extra or different entry: a described method is in the receipt filter exactly when itspolicy.ownReceiptis true, and the call table holds exactly the descriptors that bindsdk.operation: "call", each with its descriptor's idempotency. Tests also compare both with the registry at run time. The receipt filter's complement is derived from the method catalog. The descriptors stay the one registry list; coordinator tools, the coordinator allowlist and Node dispatch still read them. - Why: importing the registry at run time put every descriptor wherever the request schema or the SDK client went. A list the type checker holds to the descriptors keeps D-102's exact parity without that cost.
- Rejected: a
<scope>/featuresub-entry per scope (the request schema and the typed call need every scope, so no client would import less); moving the CLI's registry reads behind on-demand imports (the standalone is one compressed bundle, so it saves no bytes); a coordinator text table apart from the descriptors (a second place to edit each tool, against D-102). - Consequences: the web renderer drops the descriptors: 58,943 fewer raw
bytes (4,105,126 to 4,046,183) and 9,925 fewer gzip bytes (920,918 to
910,993). The CLI standalone shrinks by only 252 bytes (362,248 to
361,996), since
maia mcpstill needs every descriptor for its tools, so its 355 KiB ceiling stays (1,524 bytes left); the Node standalone shrinks by 418 bytes. A descriptor change that moves a method in or out of these lists fails type checking until the list is edited.RECEIPT_LIST_FILTER_METHODSis now a sorted readonly tuple. A new catalog method without a descriptor is not filterable until it is added to the list, so a new operation that records its own receipt needs a descriptor (or a reviewed list entry). - Links: contracts, SDK reference, D-102, D-107.
D-112 A remote release ships the maia CLI that its worker MCP launches (2026-10-03) — status: accepted
- Decision: amends D-080 and D-095. The remote
install stages the
@maiaos/clibundle (maia.mjs, staged besidemaia-node.mjs) in the content-addressed release, so its digest is part of the release ID,SHA256SUMSand every re-verification, and writesversions/<release>/bin/maia(0700), a fixed/bin/shlauncher that runs that bundle on the release's runtime by absolute path. The unit's PATH lists thatbin/first, then the pinned executor directories, the runtime and the system directories; rollback rebuilds the PATH for the release it returns to and never carries the other release'sbin/over. Before the unit points at the release,bin/maia --json versionruns from/, and the install fails unless it reports the release version. The plan names the launcher's path and the bundle's digest (cli), andmaia node installprints them. Worker MCP is unchanged: it finds thatmaiathrough its D-104 PATH checks. - Why: the remote install staged no CLI and the unit's PATH held none, so
every remote coding Session failed
Worker MCP found no maia on the Node's PATHuntil a person installed one by hand. Shipping it in the release pairs the Node with the CLI built from the same source, and upgrade, rollback, pruning and uninstall handle it with no new step. The CLI accepts only its pinned Node.js version, and a pinned executor's directory ahead of the runtime can hold anothernode(on the dogfood host~/.local/bin/nodeis v26), so the launcher never looksnodeup on PATH. - Rejected: the bundle itself as
bin/maia: itsenv nodeshebang takes the firstnodeon PATH; moving the runtime ahead of the executor directories, which would run executors on an interpreter they were not pinned with; worker MCP launching a CLI found beside the Node bundle instead of on PATH: a second resolution path in a security-sensitive launch, and local Nodes, installed by npm, have no such neighbour; amaia-node mcp --workercommand, which would put a client inside the Node; installing the CLI into~/.local/bin, which is outside the root Maia owns and lists, survives uninstall and is group-writable on Ubuntu. - Consequences: a stage without
maia.mjsfails withartifacts_invalidand names the file to stage; the release ID changes when the CLI does, so a CLI-only change is a new release (about 0.35 MiB more to upload). Amaiathe user installed elsewhere on the host no longer matters to the remote Node. As for pinned executors, the Node does not re-verify the launcher or bundle digests when a worker starts; the owner-only release directory keeps other users out. Rolling back to a release installed before this change leaves itsbin/absent, so worker MCP fails as it did then. Local Nodes still use the CLI the person installed on their PATH. - Links: remote install, launch details, D-104.
D-111 The vouch policy applies the probe's private-group rule (2026-10-03) — status: accepted
- Decision: amends D-085 and the vouch policy's file check. The
Node vouches for an executable (
integration plan,applyand each launch) when its owner is the Node's user or root and nobody else can write it, the user's private group aside, with D-085's definition of that group read from/etc/passwdand/etc/group. One rule serves the Node, the remote probe and the Linux process host:ownershipProblemanduserPrivateGroupIdin the launch scope, shell twins the probe runs, andmaia_user_writes_onlyinlinux.c's target check (the host reads the account files once at prepare, so a script's re-check after Landlock gets the same answer), each tested against the same cases. The macOS host keeps refusing any group write: macOS accounts sharestaffand have no private group. A file the policy refuses for its shape, owner, mode, links or size fails withconfiguration_invalidand a message that names the path, its mode and the fix (for examplechmod go-w '<path>'), nottransport_failed. A file that changes under the check still fails with the generic refusal. - Why: D-085 fixed the probe alone, so a remote install pinned Codex
from a 775 npm tree and then
setupandintegration plan codexrefused it withtransport_failed, whilesession createpointed at that same plan as the next step. - Rejected: raising
maximumVouchedTargetBytes(256 MiB) to fit Codex's 285 MB musl native binary: the policy hashes the whole target at plan, apply and every launch, and Codex's npm entry scriptbin/codex.jsis the target the install pins; a looser rule for the Node than the probe. - Consequences: the 256 MiB bound stays; pinning the native binary is refused with a message that names the entry script (D-145 lifts the bound for harness CLIs, Codex included). The vouch covers that script alone, not the native binary it starts or the Node.js interpreter. Like D-085, the rule reads only the local account files and the mode bits, not directory-service members or ACLs, and skips a line whose gid is not plain digits, as glibc does. A platform without POSIX user ids fails closed instead of skipping the owner check.
- Links: D-085, remote install, vendor executors.
D-114 Maia extensions package existing primitives; no extension code runs in the Node (2026-10-22) — status: pending owner
- Decision (owner direction, 22 October 2026): support third-party
extensions and Maia's own validated ones, starting with a GitHub extension
so agents stop using a person's
ghCLI. Proposed shape: an extension is a digest-pinned package of optional Skills, Agent and Routine templates, rules, tool servers, event sources, one sandboxed service and eval rubrics, under the provenance, compatibility, capability scoping, confirmation, isolation, rollback and audit contracts in extensions. Open for the owner: the manifest format, where Maia's own extensions live in this repository, the signing identity (shared with E1's release signing), the extension kit package (the GitHub tool choice is settled by D-121); the directory's index repository, review staffing, ranking and any paid promotion. - Why: AGENTS.md defers runtime extension loading until those contracts exist; composing only existing primitives keeps one authority and one operation contract.
- Rejected: in-process Node plugins and extension-defined protocol operations, Connection kinds or client views, which would create another authority; ambient credentials for extension code.
- Consequences: feature coverage no longer lists runtime plugins as extension points beside event sources and MCP tools; S61 becomes the first Maia extension plus a built-in GitHub App Connection kind.
- Links: roadmap, extensions.
D-115 Maia Cloud runs the released product; its control plane lives in a private repository (2026-10-22) — status: pending owner
- Decision (owner direction, 22 October 2026): offer Maia self-hosted and self-managed, self-hosted and managed by Maia, or fully managed. Proposed: every mode runs the released Node, CLI and Desktop with one Control Node per organization; the managed service's control plane, infrastructure as code, runbooks and service compliance evidence live in a dedicated private repository that consumes only released artifacts and public SDK operations, while this repository keeps everything a self-hoster needs. The repository is decided: a private repository (owner, 22 October 2026), which also holds the Maia Cloud plan and the first production migration plan. Open for the owner and tracked there: approval of the hosted product (AGENTS.md), regions, pricing and seats, and the support model.
- Why: one product keeps self-hosters and cloud customers on the same code and security fixes; a separate repository keeps staff and infrastructure credentials, continuous deploys and commercial code out of the open product.
- Rejected: a cloud-only fork or cloud-only operations; one shared multi-tenant Node, which D-002's single writer and tenant isolation rule out.
- Consequences: capabilities the cloud needs (S58, RL4–RL6, MN4, S63, S70, ORG, E2) ship in the open Node first; the GDPR privacy and processor documents become required with the fully managed mode.
- Links: roadmap.
- Note (D-205, 3 November 2026): pricing, seats and access are tracked outside this repository since D-205.
D-116 OpenShell is the default containment; remote sandboxes come as Maia verified extensions (2026-10-22) — status: accepted
- Decision (owner direction, 22 October 2026): NVIDIA OpenShell is Maia's default containment, and its dependency is approved (S47). Remote sandbox providers, such as hosted sandbox services, come later as Maia verified extensions implementing a sandbox provider contract (S47f), so an Agent or Routine step can be placed on a named remote sandbox. The owner's production migration waits for S47a and S47b rather than running on host containment. Merging pull requests is Workspace configuration: a tool a person grants to exact Agents for exact repositories, with the repository's branch protection enforcing checks and reviews; Maia has no auto-merge logic or standing approval for it. The other GitHub write tools, such as opening a pull request, follow the same grant model.
- Why: owner direction; one containment model for every executor, and merge policy owned with the rest of a Workspace's Skills, Agents and tool access.
- Rejected: a production migration on host containment before S47b; an auto-merge feature or standing merge approval in Maia itself.
- Consequences: S47's entry still records the superseded containment parts of D-015, D-018, D-019 and D-110, the pinned release, gateway and image digests and the SDK path at S47a. A sandbox provider adapter holds execution authority, so only Maia verified extensions may provide one, and each passes the S47 escape suite before admission. D-150 amends the cutover sentence: it no longer waits for S47a and S47b.
- Links: roadmap, extensions, production migration.
D-117 Maia's development runs on Maia, in parallel lanes, after PD0 (2026-10-22) — status: accepted
- Decision (owner direction, 22 October 2026): maximise parallel agent work, shipping speed and dogfooding. Maia's own development is its first production deployment (SH0–SH3), one step ahead of production setups. Work runs as one operation per pull request in lanes ordered by the roadmap's dependencies, and PD0 first removes the shared files nearly every operation edits.
- Approved with it (owner, 22 October 2026): PD0's method, which extends the per-feature registry, derives pinned counts and replaces the CLI bundle's exact pins with one budget. Protections are policies (D-124); a merge queue and a code-owners file are optional repository settings.
- Why: the hand-kept operation lists, export barrels, grammar digest and exact CLI bundle pin make almost every pair of operations conflict, so agents serialise. Running Maia's roadmap through Maia finds production gaps on a repository the owner controls, before the companies' cutover.
- Rejected: dogfooding only on production setups; Maia-wide protected paths (D-124).
- Consequences: SH2 gates the first production migration; the production deployment's read-only shadow does not wait for it. D-150 lifts that gate. The CLI bundle gets an explicit budget for the remaining roadmap with a recorded reason, or is split. PD0 updates the CLI rules' hard ceiling to that budget.
- Links: roadmap, self-hosting.
D-118 OpenShell through its official SDK, pinned behind one Effect adapter (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): S47a talks to OpenShell through the official OpenShell SDK at an exact version, with the gateway and sandbox image pinned by digest, behind one narrow Effect adapter, as Pi's official packages are.
- Why: the vendor owns its protocol; one pinned dependency costs less upkeep than a hand-written client.
- Rejected: a hand-written client over the gateway protocol.
- Consequences: S47a's entry records the superseded containment parts of D-015, D-018, D-019 and D-110 and the pinned digests; no further owner decision gates S47a.
- Links: roadmap, D-116.
D-119 The run ledger is the one task primitive, with a per-step retry budget (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): Routine runs, their steps and S53's nested tasks all live in the run ledger. A per-step retry budget replaces S19's fixed three-dispatch stop for Routine runs. Amends D-028 and D-073.
- Why: one implementation and one audit trail for every unit of agent work.
- Rejected: a separate task store beside the ledger.
- Consequences: S50 and S53 no longer wait for a decision entry; S19's local-v1 convergence moves onto the same ledger.
- Links: roadmap, swarm architecture.
D-120 maia agents manages Agent definitions; maia agent keeps launching Maia agent (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): S49's CLI noun is
maia agents(list,show,create,revise,disable);maia agentkeeps launching Maia agent. - Why: no breaking change to the existing launcher.
- Rejected: moving the launcher to free
maia agent;maia agent-def. - Consequences: CLI help and completion say which noun does what.
- Links: roadmap.
D-121 The Node calls GitHub itself through Effect's HTTP client (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): S61a's GitHub operations are Maia's own, on Effect's HTTP client in the Node. This settles that open choice in D-114.
- Why: every write passes the Node's grant and policy check before a credential is attached (write boundary); no new dependency.
- Added (owner, 23 October 2026): access is one GitHub App per Workspace with Node-owned operations; issue selection is a generic Projects v2 field, label or assignee filter.
- Rejected: wrapping the official GitHub MCP server, which holds the token itself; it may still be admitted later for reads through S71.
- Links: extensions, D-114.
D-122 Managed secrets: macOS Keychain and systemd credentials on Linux (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): E2a builds D-010's one
SecretStoreEffect service with two backends: macOS Keychain, and systemd encrypted credentials (host key or TPM) on Linux servers. Managed mode has no plaintext fallback. Until E2a ships, S24's gateway keys and S61a's GitHub App key stay in owner-only files in the Node root. Supersedes D-010. - Why: OS custody on both platforms without a new service.
- Rejected: owner-only files on servers indefinitely; an external vault or cloud key service as a requirement for self-hosters.
- Consequences: E2a has no pending decision and lands before MN1 and the new production servers. Its macOS gateway slice is a Source candidate (D-139); E2a and Linux MN1 stay incomplete until the Linux backend qualifies.
- Links: roadmap, D-010.
D-123 Maia stays the meta harness; a task may use its harness's native goal (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): a Maia task opens a harness
Session, and that Session may use the harness's native goal (Claude Code
/goal, Codex goals) where available. Maia keeps the task, its goal, check and budget; the harness's verdict is advisory and Maia's check settles the task. Worker launches do not disable native goals. - Why: use each harness's full capability while Maia stays the one owner of task truth.
- Rejected: disabling native goals in worker launches.
- Consequences: S69 still needs a decision entry for Node-sent continuations where a harness has no native goal.
- Links: roadmap.
D-124 Protections are policies the owner chooses; Maia hardcodes none (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): protected paths, merge rights, workflow-file edits and similar protections are policies chosen per organization, Workspace and Project (E5, S47e): an organization may set lockable defaults, a Workspace sets its own, and a Project may narrow but never widen. The Node enforces them at every write, and those that concern the sandbox compile to OpenShell policy. Maia ships none of them built in; without a policy, grants apply and an Agent with no grant is asked.
- Why: each organization decides how much autonomy its agents get; one policy system instead of rules scattered through Maia.
- Rejected: a built-in list of protected paths or merge rules.
- Consequences: the self-hosting doc's list becomes a suggested starting policy for this repository's Workspace; merge queues and code-owners files are repository settings the owner chooses.
- Links: self-hosting, extensions.
D-125 The owner's sessions commit straight to main; worker units use pull requests (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): the owner's own sessions, including
the coding agents the owner drives, commit directly to
mainbehind the pre-push quality gate. Units that Maia's workers build (SH0 onward) still land through fork pull requests and DX3's aggregate check. - Why: the fastest path for the owner's work until Maia builds itself; workers stay behind the boundary SH0 needs.
- Rejected: a pull request for every owner change; workers committing to
main. - Consequences: the repository's "pull request required" ruleset needs a
bypass for the owner, a repository setting the owner applies (D-124). CI
runs after each push, and a red
mainfollows DX3's revert-first rule. - Links: development, self-hosting.
D-126 DX4 security: Secret Protection, zizmor and cargo-deny; alert triage first (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): this private repository uses GitHub Secret Protection (secret scanning and push protection), not the paid code scanning or dependency review. zizmor checks workflow files and cargo-deny checks the Desktop's Rust crates for advisories and licenses; both are pinned, run only in CI and never ship. The open Dependabot alerts are triaged before new feature slices: each critical and high alert is fixed or gets a dated exception.
- Why: push protection stops a secret before it lands, and the two tools cover workflow and Rust gaps the in-repo lint policy cannot, adding nothing to the product.
- Rejected: code scanning and dependency review for now; upgrading every dependency in one batch.
- Consequences: enabling Secret Protection is the owner's billing action. After the triage, CI fails on a new unrecorded high or critical advisory.
- Links: roadmap, development.
D-127 Tool connections speak MCP through the official SDK, pinned (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): S71's Node talks to MCP tool servers through the official MCP TypeScript SDK at an exact version, behind one narrow Effect adapter, as D-118 does for OpenShell. Admission stays S71's: per Workspace, by exact version and digest.
- Why: the protocol moves often, and the vendor's package already sits in the lockfile through an existing dependency.
- Rejected: a hand-written MCP client.
- Consequences: S71's entry pins the version. MCP still never owns Workspace, Project or task truth.
- Links: roadmap, D-118.
D-128 Session stream records are kept 30 days by default (2026-10-22) — status: accepted, amended by D-168
- Decision (owner, 22 October 2026): S12r purges a Session's stream records when it is archived and 30 days after its last activity. A Workspace policy may set a shorter period, or a longer one within the organization's limit (D-124).
- Why: records keep command output and paths unredacted until masking (S65) ships; 30 days keeps recent history for debugging.
- Rejected: 7 days (too little history) and 90 days (more unredacted data at rest).
- Consequences: settles the retention default D-077 left open.
- Links: roadmap, D-077.
D-129 The Node pin and the Pi RPC interpreter pin move to 24.22.1; migration 59 rebuilds the authorization chain (2026-10-22) — status: accepted
- Decision: the repository's exact Node pin (
.node-version, everyengines.node), the Pi RPC interpreter pin (PI_RPC_NODE_VERSION) and the protocol's interpreter literal move from 24.19.0 to 24.22.1, the October 2026 security release, in one change. Migration 59 rebuilds the fivepi_rpc_authorization_*tables from the frozen version-13 and version-14 text, so the plan table's interpreter-version CHECK admits'24.19.0'and'24.22.1'. Shipped migration 13 stays as it is. A new plan carries the pinned version, which the plan store checks; the second value keeps the stored rows. - Why:
engineStrictwith an exactengines.noderejects any other Node at install, and the site image installs the whole workspace. The Pi RPC contract comparesprocess.versions.nodewith its pin before it builds an artifact. A shipped migration's checksum is verified when a database opens, so editing version 13 would refuse every existing database. SQLite cannot alter a CHECK, and the plan table is the parent, under a RESTRICT delete rule, of the other four tables, so they are rebuilt together as migration 40 did for the integration chain. - Rejected: editing migration 13; relaxing
engine_strictin the site image alone (the toolchain and CI would stay on the unpatched Node); a CHECK that checks the version's shape and never needs a rebuild (it drops the database's exact-version guard; a candidate for a later decision if Node pin changes keep arriving); deleting the stored plans (they are immutable evidence, and migrations preserve rows). - Consequences: while the CHECK names versions, a later change of the Pi RPC pin needs its own migration. Plans stored under 24.19.0 stay readable, expire after five minutes, and the artifact check refuses an interpreter whose version differs from the pin. The CLI standalone size pins and digest were re-measured on Node 24.22.1.
- Links: D-060, release.
D-130 The pre-push hook runs the change's tests, and each change has a source growth budget (2026-10-22) — status: accepted
- Decision: the owner asked on 22 October 2026 for hooks that stop a push
CI would fail, and for a check that weighs new code against deleted code.
The pre-push hook runs
pnpm check:changed, which adds the tests of the packages the change can affect to whatpnpm check:qualityran. Every change has a growth budget: net source lines its commits add, without tests, fixtures, documentation, declarations or generated files, warn above +200 and fail above +400 unless a commit carriesGrowth-Accepted: <reason>. The pre-push gate and CI (per pull request, per push) check it. - Why: CI took about 8 minutes to report what the machine finds in 4 seconds to 3 minutes. The 700 commits before October 2026 added 3.6 source lines for each one they deleted; nine in ten stayed under +272 net lines, so +400 stops the outliers and leaves the usual change alone.
- Rejected: tests in pre-commit (too slow for every commit); a ratio of added to deleted lines (a small change with no deletion would fail); counting tests (it would discourage them); checking the budget only after a push to main (the failure would land after the merge).
- Consequences: a push that changes the Node runs its tests, about 2
minutes on a 12-core laptop;
--no-verifyorMAIA_SKIP_HOOKS=1skips once. A follow-up push to a branch the remote already has checks only what changed since the remote's commit, trusting that commit passed the gate on its own push; CI still checks the whole change, and catches what a commit pushed with--no-verifyor from another clone breaks. The thresholds change only in a reviewed commit. - Links: development, D-125.
D-131 Pull requests merge by squash only (2026-10-22) — status: accepted
- Decision (owner, 22 October 2026): the repository allows squash merges only; merge commits and rebase merges are off.
- Why: one commit per pull request on
maingives a linear history in which each change reverts in one step. - Rejected: rebase merges (every branch commit lands on
main); merge commits. - Consequences: GitHub's default squash message stacks the branch's
commit messages, which leaves a trailer of any commit but the last
mid-message, where Git's trailer parser misses it. The growth budget and
the metrics check read
Growth-Accepted:andMetrics-Accepted:trailers in every paragraph of a commit message that holds nothing butKey: valuelines and their indented continuations, so the default message keeps them and a key that starts a line of prose counts for nothing. The owner's direct commits tomain(D-125) are unaffected. - Links: development, D-130, D-125.
D-132 CI publishes canary and nightly builds; stable and next stay blocked (2026-10-23) — status: accepted, amended by D-202
- Decision (owner):
.github/workflows/publish.ymlpublishes through npm trusted publishing (OIDC, no stored token):- nightly
0.1.0-nightly.YYYYMMDD.Nafter each green CI run of a push tomainthat changed a public package, whileNIGHTLY_ENABLEDistrue.latestfollows nightly until a stable release exists. - canary
0.1.0-canary.Non demand from any green branch; it never moveslatest. - Stable and next stay blocked until the owner lifts this decision:
publishedChannelOfrefuses them in every release script. - The tested tarballs are the published bytes, and a nightly moves
latestonly after it installs from npm and gets its tag. - pnpm does every install and registry read; npm runs only in the two OIDC jobs, because pnpm 11 cannot move a dist-tag through OIDC.
- nightly
- Why: dogfooding needs each merged change installable within minutes, and no stable release yet. Amends D-043.
- Rejected: publishing from a pushed tag (a tag can name a stable version); pointing stable at the newest canary (branch builds would reach every default install).
- Consequences: a repository writer can still publish a canary from a modified branch; a protected environment on the trusted publishers would close that (planned).
- Links: release.
D-133 A local lab drives Nodes through a gateway Connection to a local model proxy (2026-10-23) — status: accepted
- Decision:
pnpm dev:labruns one to four detached Nodes from a dev worktree, each with its own profile,HOMEand share of the worktree's ports. Their model calls use a gateway Connection to a local model proxy the owner chooses in local environment variables. Live calls are opt-in, read-only and capped at one turn per executor. Stress runs use an offline fake gateway on loopback instead. - Why: dogfooding Codex and Claude Code through Maia needs several real Nodes beside the existing one, without touching that Node's state, accounts or default profile.
- Rejected:
MAIA_NODE_TEST_RUNTIME_ROOT(test mode turns vendor integrations off); a lab-owned proxy or key store; a hostname or key path in a committed file. - Consequences: the key lives in each lab Node's custody file, reaches it
through standard input, and goes with
down --purge. It never appears in argv, logs or evidence, or in the environment of the lab launcher, the CLI or the build tools. The one exception is the vendor executor a Node starts for a Session: it gets the key in its own environment, as the gateway Connection defines (MAIA_GATEWAY_API_KEYfor Codex,ANTHROPIC_AUTH_TOKENfor Claude Code).HOMEisolates Node state until RL3 gives each profile its own home.uprecords theTMPDIRit starts the Nodes under, and every lab command,dev:worktree removeanddev:cleanuse it. One lock ordersup,downand those removals, and is never taken over: a stale lock is removed by hand.dev:worktree removeanddev:cleanrefuse while a lab Node runs.downstops a Node through the authenticated SDK shutdown request, using the SDK already built and never building, and waits for it to exit. The lab refuses a symbolic link on the way to any folder it creates or hands to a Node and anywhere under a Node'sHOMEorMAIA_HOME, at creation and before every launch. It skips a Node that already runs, and allows only the helper links Codex makes in a Session's Codex home under the Node's state folder (tmp/arg0-macos/codex-arg0<random>/apply_patchorapplypatch, as Codex 0.163.0 makes them on macOS) to thecodexexecutable the Node resolves from itsPATH. A process of the same user that swaps a link stays out of scope for the whole window from preflight onward, between the scan and the launch as much as after it; helper links are trusted as Codex's own. The smoke makes no gateway call of its own and registers its fixture under a fixed command ID, so the Project is the generated fixture. The lab does not list the proxy's models: a proxy may name a model differently from what a client sends, so the turn tests it.scenario stressreads no key: it logs each Node in to a loopback fake gateway with a fixed offline key, refuses a Node unless itslab-stressConnection is the enabled gateway Connection to that gateway's loopback URL, and bounds its load (at most 4 Sessions at once on each Node and 40 turns per Node). It gives each create a command ID of its own, built from a run ID with a random suffix. When the answer is lost or its outcome is unknown, it reads the receipt again until one monotonic deadline, since the Node may commit after the client left, and keeps a command ID with no receipt at the deadline as unresolved. It attributes descendants and ports only to Nodes that still have the identity the run first saw. It stops every Session it created before it closes the gateway, and closes the gateway before it releases the lock. It records latency, throughput, memory and CPU as amaia.metrics/v1labsnapshot; an unexpected outcome, an unresolved Session or create, or a leftover (a process or a listening port, matched by process ID and start time) fails the run but is no metric. - Links: development, D-042.
D-134 One @maiaos/node tarball carries the darwin-arm64 and linux-x64 process hosts (2026-10-23) — status: accepted
- Decision:
@maiaos/nodestays one universal tarball. It packs thedarwin-arm64andlinux-x64process hosts, and the Node starts with the one for its platform from the list embedded at build time; a platform with no entry fails closed.publish.ymlcompiles the Linux host on GitHub-hostedubuntu-24.04from the release commit, the macOS build embeds it, and the tarballs install and start the Node on macOS and on Linux before anything publishes. Linux ships through nightly and canary only; D-132 still blocks stable and next. - Why: it adds no package name and leaves the package count unchanged, and one workflow run builds both hosts from one commit, so the bundle embeds digests of hosts that match its sources.
- Rejected: a package per platform with optional dependencies (new names, new trusted publishers, and a host built apart from the bundle that embeds its digest); publishing the Linux host without starting the Node on Linux.
- Consequences:
npm-packages.mjs verifyrefuses a Node tarball that lacks either host or packs one without mode 755, and the build refuses a Linux host recorded against another commit. The Linux check proves installation, startup, authenticated status and shutdown; it launches no worker, though Linux startup qualifies the managed Pi worker (macOS uses Pi RPC). The build embeds that worker's identity: files by content, the manifest by the fields that decide loading (packing rewrites the rest), and the release version, so the packed tarball matches what the build saw. Positive containment evidence needs the native-host suite on a kernel with Landlock ABI 6 or later; below that, the suite proves only the refusal. The install check clones or copies files, because the Node refuses a host with a second hard link. The Linux host is still unsigned and not release-qualified. Linux stays a source candidate until a nightly with it installs on Linux. Amends D-132 with two jobs and no looser rule. - Links: release, Linux host, operations.
D-135 Repository integrity: signed commits on every branch; main cannot be deleted or force-pushed (2026-10-23) — status: accepted
- Decision (owner): every commit on every branch has a verified signature,
and
maincan be neither deleted nor force-pushed. Two GitHub rulesets enforce this with no bypass (25688154, 25688193); the pre-push hook andpnpm dev:doctorgive early feedback. - Why: supply-chain integrity: what reaches the repository is attributable
to a key, and
main's history stays whole. - Consequences: agents and CI that create commits must sign them (GitHub signs its squash merges). The hook checks that a signature exists; GitHub verifies it. The older organization ruleset (24652061) has the same deletion and force-push rules but lets organization admins bypass them.
- Links: development, D-125, D-131.
D-136 The first owner is confirmed at the controlling terminal over an inherited channel (2026-10-23) — status: accepted
- Decision:
maia node bootstrapis the only way to create the first owner of a Node. The CLI starts an empty Node for the profile with a private pipe inherited as file descriptor 3, previews over the loopback API, asks forYES <preview-id>on/dev/ttyand writes one confirmation record to the pipe. Apply commits only when that unread record matches the preview and the apply request exactly. It verifies authority once it holds the write permit and judges ownership and expiry inside the SQLite transaction, against a clock read after the database lock is held. The receipt stores a hash of the whole apply request, so a retry with another name or preview ID is refused. Only one bootstrap call runs at a time; an overlapping call is refused at once with the retryablesaturatederror instead of queueing. Both operations stay release-disabled. Authority is the OS account that owns the profile (D-013). - Why: the local bearer is transport authentication, so holding it must never create an owner. Only the process that launched the Node can write an inherited descriptor.
- Rejected: a proof token in the preview result (any bearer holder could replay it); a named pipe or path (any process of the account could write); confirmation from standard input (a pipe can answer it).
- Consequences:
node bootstrapis the one CLI command that starts a Node and stops it when the command ends. It needs a POSIX controlling terminal andmaia-nodeonPATH. A Node already running for the profile refuses withbootstrap_channel_invalid; stop it first. A Node that fails to start is reported after the 30-second readiness deadline. Apply waits up to five seconds for the Node to read a confirmation the CLI just wrote, and a preview that expires closes its channel without another request. The mapping from the profile bearer to the created owner is Planned, not built here. The terminal prompt is an operator ceremony, not proof of physical presence. The CLI standalone ceiling rises from 355 to 360 KiB: the slice adds 4,200 bytes to a 362,728-byte executable. The packed-size review approves 8,000 more bytes, and the packed reference moves to the 449,648 bytes of the tree before the slice. - Links: specification, CLI reference, contracts.
D-137 One machine installs exact builds side by side and switches between them offline (2026-10-23) — status: accepted
- Decision:
maia-node release install|use|statusare offline commands of the Node executable, next tostate check. On darwin-arm64, one machine:installtakes an exact nightly or canary version, or a channel name that npm resolves to one, and buildsversions/<v>/in the Maia home from the release's digest-checked tarballs and the install project CI tested (its lockfile and pnpm version) with that pnpm, in a home of its own (pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile). An installed version never changes again. Stable and next stay refused (D-132). The files come from the repository's release asset endpoint; the one redirect GitHub answers with is followed only to its download hosts and without the token.useswitches the selected version for one explicit, stopped profile. It previews by default and applies with--yesor the digest of the exact plan. The target build runs its ownstate checkfirst, and onlyabsentorreadableswitch;migrates,newer,divergedandunreadablerefuse and name the verdict. The build must also declare the start guard in that check. A home serves one profile: the firstusebinds it incurrent, and a switch or a start for another profile refuses until a later slice adds a rebind.- The install lock and the profile's instance lock serialize every change.
A running Node gives
node_running(exit 75). One atomic rename of thecurrentpointer commits a switch. The launcher inbin/checks the file before it runs a build, and every Node that runs from aversions/<v>tree re-checks under the profile lock, from the resolved location of its own bundle and whatever the environment says, that it is the selected version for the bound profile.
- Why: dogfooding moves one machine between nightly and canary and back. A build must not open data it cannot read, and the tree that runs must be the tree CI tested.
- Rejected: a symlink for
current(the pinned interpreter and the previous version need the same atomic write); installing registry specs at install time (a tree nobody tested); database restore or migration inside a switch (RL4, with backups); moving the home or the state layout (RL3); a protocol operation (these commands run without a Node, so they are not part of the operation registry); a signature check (no signing identity yet, RL5). - Consequences: a release without the
maia-install.jsonasset cannot be installed, and a build that does not declare the start guard cannot be switched to. One home serves one profile until a rebind exists. The commands launch pnpm through the Effect spawner, asremotelaunches ssh, not through the Node's process manager (D-018): they run without a Node, and the manager admits runtime workers by platform tier. The evidence that an earlier install has stopped is the process list (pgrep -ffor the--dirargument), so a descendant that drops the argument is not covered. Durability rests on the order of the flushes, which tests check, and on Node asking macOS forF_FULLFSYNC; no physical power-loss test backs it. A process of the same OS user that rewrites the Maia home between two steps of a command is outside what the installer defends against; it refuses the inconsistent state it can detect. The owner may require the manager instead. The release digests rest on read access to the GitHub release until RL5 signs them. Hostile code running as the same user is out of scope. Planned, not built: database restore, switches that migrate, pruning, home relocation,maia update(RL4), Linux, auto-update and remote hosts. - Links: release, operations, D-132, D-042.
D-138 actor_local_user is a legacy local transport actor; administrator actions name the authenticated User, client and Device (2026-10-23) — status: accepted, amended by D-150
- Decision:
actor_local_userremains the actor of a request authenticated by the local profile bearer. It names a transport, never a person, and stays unchanged in stored receipts, events, approvals and the SQLite CHECK constraints that require it. A new administrator action (enrollment, grant, rotation, revocation) is attributed to the authenticated User, client and Device that took it and never toactor_local_user.receipt list --actoris an exact filter on the stored actor ID: it does not follow a User to the actors behind it. - Why: more than 40 non-test source files and several CHECK constraints depend on the literal, so rewriting it would change recorded history, while attribution can start at the first administrator action. The bearer proves which OS account owns the profile, not which User acts (D-013, D-136).
- Rejected: a global alias from
actor_local_userto the bootstrap owner, which would rewrite who acted in past receipts; resolving the bearer to the owner before a managed credential exists. - Consequences: mapping the bearer to the bootstrap User stays Planned, so no administrator action exists yet; MN1 needs the managed administrator credential first (the first version is amended by D-150). Receipts may carry the legacy actor or a User actor, and a reader filters each exactly.
- Links: principals, first-owner bootstrap, CLI reference.
D-139 The first managed SecretStore slice stores gateway keys in the macOS Keychain on fresh profiles (2026-10-23) — status: accepted
- Decision: E2a starts with one slice, labelled Source candidate. One
SecretStoreEffect service has three backends: memory for tests, today's owner-private files and the macOS Keychain through/usr/bin/security -i.putcreates and never replaces; the service has no generic delete or replace. A profile chooses managed storage once, withMAIA_SECRET_STORE=managed, on macOS, while it holds no Connection. The Node writessecret-store.json(backend, exact keychain path, store identity) durably before the first secret, and every later start follows it, syncing the directory first. The write runs in the caller's scope under the profile lock, closes its descriptor before it removes the temporary file it created, and has no deadline of its own: a stalled file system stalls start-up just as it stalls the lock and the database. An interrupt reaches the steps between native calls, never a write, sync or link already submitted, so the lock never frees under a pending call. One that lands after the link leaves a real marker, which the next start syncs before it exposes the store. One no-follow, non-blocking open reads the marker: a link, a FIFO, a directory or an oversize file in its place fails closed. Gateway key writes and both launch consumers go through the service, which the Node provides as a Layer under the profile lock. A duplicate add reads and validates the retained item before it reportskeptand never overwrites a valid first writer. Eachputfirst runsshow-keychain-infoon the named keychain: in a GUI login sessionsecuritystores an item in the default keychain, and exits 0, when the named file no longer exists. The process manager admits/usr/bin/securityfor the newsecret-storeowner in thevouched_runtimetier only in its fixed envelope: arguments exactly-i, an empty environment, piped stdin, working directory/and asecret_store_<uuid>command ID. It refuses any other owner, argument, environment or path at admission, and the runner stays private to the secret-store scope (amends D-018). - Why: an independent review of the E2a design approved a macOS-first slice and rejected splitting activation from migration safety. This slice needs no migration because it refuses populated profiles.
- Rejected:
-Uupserts and any in-place replace; secrets on argv or in the environment; converting a populated profile at startup; a downgrade to local files when the keychain is reset; one size limit for both modes (local keeps 4096 bytes, managed holds 2048 and checks it before the Connection record exists). - Consequences: the Keychain trusts
/usr/bin/security, so any process that can run it as the same macOS user can read an item. This is OS-account custody (D-013), not isolation from workers or other processes of that user. The claim is no intentional argv, environment or log persistence by the Node's secret-store path, not crash-report or memory-dump immunity. The key still enters a Claude Code or Codex worker's environment at launch (harness/gateway/launch.ts, D-071), so managed custody keeps it at rest in the keychain, not away from a worker. A lost or reset keychain isrecovery-requiredfor each affected key: reads fail with a repair message and no retry, and the owner enters the key again under a new Connection name. A locked keychain fails at once as locked in a background session; a GUI login session holds the call at an unlock prompt until the 10 s deadline, then reports a retryable timeout that names the lock. Deleting the marker is not recovery: it orphans the stored keys andmanagedis refused on a profile that has a Connection. An unreadable marker fails closed. A profile-level recovery state, administrator and CA custody, rotation, removal and migration stay Planned; the Linux backend is D-155. - Links: operations, Node rules, D-122.
D-140 release update installs a channel or version and selects it in two confirmed steps, finishing unfinished work first (2026-10-23) — status: accepted
- Decision:
maia-node release updateis the first RL4 slice. It takes a version,nightlyorcanary, a--profile, and--yesor--confirm <id>:<digest>. It is offline like D-137.release usekeeps its plan, digest and refusals; applying a switch also ends the updates that had not finished:- A preview resolves the request to one exact version, installs it, rebuilds
the
release useplan and writesupdates/<id>.json: the target, the plan digest, the whole selection it expects (previous version included), the canonical home, the database's real location (links resolved, a missing tail kept; an unresolvable link refuses), the actor and a ten-minute expiry.--yespreviews and applies in one run. - The confirmation is
<id>:<digest>. The id is random per preview and names a record; the digest stays the plan digest. Applying rebuilds the plan offline from the saved version under the install and profile locks and compares the digest, the complete selection, the home and the database's location with the record. Any difference abandons the record and needs a fresh preview. A used confirmation reports its receipt, markedsupersededwhen the selection has moved on. - The command reconciles first. Under the install lock, before any channel
lookup or install, it reads the records. An unfinished one is marked done
when the pointer shows it (a retry for that target gets the receipt),
finished offline on its saved version for the same target with
--yesor its confirmation, or named in a refusal. Only a request without unfinished work resolves a channel and stages. Arelease usethat switches the selection ends the unfinished updates first, so an old confirmation cannot undo it. - Disk admission clears an interrupted install's staging, then runs before
the download and again, with both locks held, before the commit. Each affected filesystem needs the bytes written on it plus a
separate free-space reserve. The constants (2 GiB install, 1 MiB per
commit write, 512 MiB reserve) are provisional. ENOSPC fails with
release_failedand leaves the update resumable. migratesstays refused. The slice has no migration state and no process attestation.
- A preview resolves the request to one exact version, installs it, rebuilds
the
- Why: RL4 needs a small first step before restart, backup and drain. A channel may move between a preview and its confirmation, so the identity of what the user confirmed must be the record, not a digest that a later preview of the same plan shares. An interrupted update must finish from what it saved, not from whatever the network says next, and a disk that fills must leave work that a rerun finishes.
- Rejected: one confirmation digest for the plan and the identity (two previews of one plan could confirm each other); resolving the channel before looking for pending work (a retry could install a third build); a fixed safety margin without a reserve (a full disk at the commit); migrating switches, backups, drain and restart, which stay with the rest of RL4.
- Consequences: the sizes are not measured and err high; they need
replacing after an install is measured on a real disk.
plannedrecords are never pruned. A home that holds anapplyingrecord refuses other updates until the same command finishes it, or until the pointer shows its result. Tests inject the ENOSPC failure; none fills a real disk. - Links: release, D-137, D-132.
D-141 Several servers share one CLI over --node and node list; ssh never trusts a host key on first use (2026-10-23) — status: accepted, amended by D-175
- Decision: until MN1 enrolls remote Nodes, one CLI manages several
servers, each running its own loopback-only Node, through what
D-080 built.
node.remote.liststays the one host list.maia --node <name> <command>is the one way to act on a host: it takes one exact registered name, so a mutation can never target several hosts, and its preview and confirmation run against that host's own Node.maia node listis the fleet read: it asks every ready host at once (four at a time, 12 seconds each) and reports for each Node its release, lifecycle, pressure, tree use (used/capacity), pid and latency, or the reason that host failed. A refusing or silent host never fails or hides another. The values are exactly what the Node's bridge greeting states, so the CLI infers no health, a read costs no extra request and no protocol operation exists. Every ssh command now pinsStrictHostKeyChecking=yes, which an option on the command line enforces over the user's ssh configuration. - Why: the owner needs one CLI over several servers now, and the bridge,
the registry, the exact-name routing and the fail-closed rules already exist
and are tested. Reusing them adds no authority, no remote command line that
carries user words, and no second path to the same hosts. Strict host keys
close the one gap:
BatchModealone still accepts a new key when the user's configuration saysaccept-neworno. - Rejected:
maia --host <name> <command>, which would run the remote CLI over ssh: a second routing path to the same hosts, with user arguments on a remote command line. Its one gain, a newer local CLI against an older remote Node, is not a current need; revisit it with that need. Afleetcommand root, which CLI principle 9 forbids andnode listalready covers. A second host list. Analltarget for mutations. Achannelfield: the version suffix already names it. - Consequences: interim and Source candidate: MN1 enrollment replaces
ssh as the transport for enrolled Nodes, and this path then serves install
only. No install step ever relied on
accept-new:BatchModealready refused an unknown key under ssh's default. A configuration that setaccept-newornonow refuses too, and an install, rollback or uninstall on a host whose key ssh does not trust fails with the command that adds it (ssh <host>, after the operator verifies the fingerprint). The pin binds the destination connection only: aProxyJumphop, aProxyCommandand a customUserKnownHostsFilestay the user's. No raw command diagnostic or journal text from the remote is shown or stored. A failing host shows one fixed reason, never whatsshprinted. A Maia script names why it refused by one ID from a closed set and Maia words that reason, so a command's output, a shell's own error or a forged line can select a fixed sentence and never add text. A readiness failure names thejournalctlcommand the operator runs and shows no log line: the Node's journal can carry a credential its scrubbing misses. Checked facts stay in previews, output and job records: validated paths, the protocol results the bridge returns and host facts that fit a shape Maia knows. A host fact is a name from a closed set (OS, architecture, systemd state, linger, probed tools), the leading version digits (kernel), a known distribution name with its numeric version (up to four components, as in2023.0.20230308), a release or runtime ID in the format Maia generates, or a validated path. A fact that fits no shape shows as<not shown>(an unknown distribution reads as unknown) and reaches no blocker, preview, stored plan or job record. Maia does not read the host's user name, unit states or unit list: a blocker says "the remote user" and "another profile", and names the numeric UID whereloginctlneeds one. An install manifest counts when it decodes by content (key order, whitespace and added keys do not matter, and an older Maia's manifest keeps working); one that does not decode reads asmanifest_invalid, and the probed text serves the compare-and-set alone.remote probe --json,remote install --jsonand a blocked failure print one summary of the facts (RemoteNodeHostFacts), never the raw facts; the same two commands andremote rollback --jsonprint the plan as its stored summary and the Node's readiness as a process ID and a containment mode, never the probed manifest text. A removal shows only when the plan named it, a leftover in the root that Maia did not create shows as a count, and a version mismatch shows a fixed sentence with the version Maia expected. The bridge's greeting is untrusted: itsversionmust be one Maia builds, and anunavailableframe shows Maia's sentence for itsreason, never itsmessage. Outside the guarantee stay the free-text fields of a registered Node's protocol results, which the protocol bounds by length alone (listed in operations); the Node that makes a result words them. A protocol error'smessageis protocol prose in the same way: it may carry host words, never a terminal command, so human output replaces every terminal control in it and every CLI--jsonline escapes them. One CLI function does the replacing for the human formatters (cellfor a line or a cell,blockfor a patch or a reply that keeps its tabs and line breaks on purpose). A formatter appliescellto each field it prints, so acellfield cannot end its line or start a cell; ablockfield can still hold both. Humansession attachfollows the same rule: a command, a tool name and its arguments, and a file path arecellfields; the rest of each frame's text passesblockonce.blockreplaces controls (so no escape sequence or color), line and paragraph separators and invisible format characters (bidirectional overrides, the zero-width space, tag characters); it keeps tabs, line breaks, the zero-width joiners and combining marks. Each line of Session text that Maia breaks prints indented two spaces, so it cannot pass for a## turnor[exitline of Maia's. The terminal's own soft wrap can still carry Session text to column 0, and copied output carries the indent (pasted Python breaks);--jsongives the exact text. The IDs and enums it prints stay as the protocol schema bounds them. The interactive sign-in shows its ownssh -tsession. The probe follows the same rule: it names an executor failure by one ID from a closed set, Maia words it, and an offending path appears only when it is a plain absolute path; an invalid probe line gets one fixed sentence that names no key. The local Node carries no host credential, and each remote Node's credential stays on its host. The owner approved an interim--hostandfleet statusdesign on 2026-10-23; this entry narrows it to the registry and transport that exist. - Links: Remote Nodes, remote install, D-080.
D-142 A Routine fire is a recorded, at-most-once launch; disable is terminal (2026-10-23) — status: accepted, amended by D-163 and D-178
- Decision: Maia Node owns the schedule. A 5-second tick on the Effect
clock makes at most 32 launch attempts, fresh claims first and pending
admissions with the rest, each claim in its own savepoint, and
writes one row per fire to the fire ledger (
routine_fires) before it launches anything. The ledger is the recorded event for every outcome, including a skipped overlap, a missed catch-up and a typed failure. A fire launches through the Node's own request path (create Session, start, first turn), so holds, guards and Connection state apply. It acts as the Node's automation, an actor kind for work the Node starts itself, with one ID per purpose and subject: receipts nameactor_node-automation_routine-scheduler_<routine ID>, neveractor_local_user(D-138). No credential resolves to that actor. Authorization admits only the fire's Project, then its own Session. Creation takes a Workspace, executor or Connection and model, never a role, definition, worktree or title. A launch runs once and settles once; only the settlement write repeats. A fire leftlaunchingby a crash resumes as started only when the turn named<fire ID>.sendlives in the Session the fire attached and its receipt is aturn.sendby that routine's scheduler actor; a person who sends that command ID elsewhere changes nothing. Any other fire becomesfailedwithoutcome_unknown; it is never relaunched.routine disableis terminal and, like create, previews, then a person confirms (an agent, as D-178 configures);pauseandresumeare the reversible states. Each preview binds the actor, command ID, Routine ID and, for create, the name, schedule, template and initial state, expires after 120 seconds, and is the only way to create or disable a Routine. - Why: a retried launch can start a second Session and spend a second turn, so the ledger row comes first and an unknown outcome stays visible instead of repeating. Reusing the request path keeps one authority for holds and guards. Writing the fire first makes the skipped and failed cases queryable without a second event store.
- Rejected: an external cron or OS scheduler, which would run outside the
Node's holds and receipts; per-slot retry of a
launchingfire; a queue of overlapping fires, which grows without bound during an outage; a separate event table duplicating the ledger. - Consequences: slots missed while a Node was down with the routine active
fold into one catch-up fire that records how many slots it covers. Slots
missed while a routine was paused never fire:
resumeschedules the first slot after the resume time, so enabling a batch does not fire it at once. Slots only move forward: an hour the clock repeats fires once, at its first pass. Each tick also stops the live Session of a settled fire whose turns are all its own and finished, whatever the fire's outcome, so the Session gives back one of the Node's 8 active slots and keeps its history and diff. The stop compare-and-sets the revision the eligibility query read, so a turn a person sent afterwards keeps the Session running.session.starton a full Node refuses withsaturated, as runtime admission does. The fire stayslaunchingas a pending admission with a deadline 10 minutes after its claim on the Effect clock, and retries the same launch each tick. No attempt starts at or after the deadline: each attempt reads the clock right before its launch, and the first tick at or after the deadline that finds the fire waiting fails itsaturated, however slow the attempts, the claim or the queue. A restart is bounded: a tick makes at most 32 launch attempts and the Node runs at most 8 Sessions. Due routines claim first, so a due routine starts within ceil(due / 32) ticks whatever waits ahead (80 due routines start within 3 ticks); pending admissions retry with the rest of that budget, oldest first, and a burst of due routines can pause their retries until it drains. A due routine the budget defers stays due and claims on a later tick. Pending admission lives in the process, so a restart drops it and recovers each fire as above. The 20-routine cap counts active and paused routines per Project, so a resume never exceeds it. A create input withpaused: trueinserts the routine paused with no next fire, so a batch of routines never fires before someone resumes it. A fire whose settlement write fails keeps its result and repeats the write each tick; its Routine skips later fires asoverlapuntil the write lands or the Node restarts. The Desktop list is not part of this slice. The CLI standalone ceiling rises from 368 to 372 KiB: S49 on main took the executable to 374,596 bytes, and the routine commands and their shapes add about 4 KiB; the merged executable measures 379,240 bytes. The package packs to 473,792 bytes and unpacks to 1,455,832, the new references. The routine commands fit themaia help --jsonbudget of D-103: the merged tree measures 67,936 bytes, under 68 KiB. - Links: CLI reference, contracts, Routine model.
D-145 A harness CLI is vouched at any size; Maia's own targets keep 256 MiB (2026-10-23) — status: accepted
- Decision (owner direction, 23 October 2026): amends D-111.
The vouch policy no longer bounds the size of a harness executable: the
claude-codeandcodexadapters admit a file of any size at plan, apply and every launch, and the Claude Code snapshot copies any size. Git, the terminal and Pi's interpreter keep the 256 MiB bound. The snapshot streams the file in 1 MiB chunks, reads exactly the size the opened file reports, and refuses a short read or a file whose device, inode, size, mtime or ctime changed during the hash or the copy. - Why: Maia uses the harness installed on the machine, whatever size its vendor ships. Claude Code 2.1.309 is 275,923,296 bytes, so the bound refused its integration plan.
- Rejected: a higher bound such as 512 MiB (a later release crosses it again); hashing once at snapshot time and trusting the snapshot afterwards (a launch would no longer check the digest it runs).
- Consequences: plan, apply and each launch hash the whole harness binary,
so their cost grows with it. A Codex executable over 256 MiB is no longer
refused; an npm install still pins
bin/codex.js.darwin-g2-evidence.mjskeeps its own 256 MiB check on the G2 evidence targets. - Links: D-111, vendor executors.
D-148 Maia detects the installed harness CLIs, proposes their updates and never runs a vendor updater (2026-10-23) — status: accepted
- Decision:
connection detectreports, per installed CLI, three separate facts, each allowedunknown: the installation and approval, whether the approved build still launches, and freshness (installed against the newest version a public registry names).connection detect --refreshasks that registry now; the answer is advisory, bounded in time and size, and records the exact version, source and check time. Maia proposes the next step as a command a person runs: the vendor's own update command, ormaia integration planwhen a newer build is installed but not approved. The person picksnotify(default) oroffper CLI withconnection update-policy set, through preview, confirmation and a receipt that names the authenticated actor; the policy lives on the active integration authorization, survives a manualintegration applyand ends withintegration remove. - Why: an installed harness is replaceable capacity, so Maia adapts to what is on the machine and keeps the approval boundary of D-084: a new binary runs only after a person approves it. Proposing costs nothing and cannot execute unapproved bytes.
- Rejected: a new
harness listcommand; runningnpmorbrew; a five-state health model; a minimum age between checks. - Consequences: the registry result is a proxy for the vendor's own
channel and says so. The cache is in memory, so a Node restart needs a new
--refresh. Setting a policy needs an active authorization, so a CLI Maia has not approved has none. The CLI standalone ceiling rises from 360 to 362 KiB for the new detect output and the policy command.auto, adopting an update the vendor already installed, is Planned (slice 2): it needs a verified stable artifact, one transaction binding the check to the approval, and honest compatibility evidence before it is built. - Links: CLI reference, harness configuration, release.
D-144 The Session turn queue ships first; steer waits for a durable dispatch guard (2026-10-23) — status: accepted
- Decision: S26 ships
turn.sendqueueing,turn.queue.listandturn.cancel. Steering a running turn moves to S26b:turn.sendhas nomodeorexpectedTurnId. Claude Code refuses steer: in astream-jsonprobe (2.1.309) a mid-turn message joined the running turn only at the next tool boundary or ran as a separate turn, with no turn guard or acknowledgement. Pi waits for S23. - Why: a queued send reserves its place by writing the turn row and its
command in one transaction, behind the Session admission (emergency holds)
and the pending-turn cap. A steer has no turn row, so it cannot reuse that
path. The steer built for S26 called the executor before any durable record:
it bypassed emergency holds, a retry after a failed receipt write sent the
message again, and the Codex acknowledgement was not validated. A safe steer
needs its own phases like
turn.interrupt(prepared, dispatching, acknowledged, outcome unknown) and restart reconciliation. - Rejected: shipping steer with the duplicate-steer and lost-receipt gaps documented; steering Claude Code by writing mid-turn.
- Consequences: S26b starts from the Codex review findings: refuse a steer
under a hold, reserve command ID, input digest and target before executor
I/O, and validate the Codex acknowledgement against
expectedTurnId. The queue's restart handoff is proven by a test that reopens the database and runs restart reconciliation on a test clock: recovery and all seven handoffs complete within a 100 ms test-clock budget. Its list, cancel, stale-revision and replay behavior is proven by a test through the SDK client over the Node API. - Links: roadmap, contracts, CLI reference.
D-150 The first version and the first production migration run on host containment, on remote servers (2026-10-23) — status: accepted
- Decision (owner, 23 October 2026): amends D-116 and
D-117, and D-138 for the first version.
- The first version and the first production migration no longer wait for S47a and S47b. Once the local lab passes, they may run on today's host containment, one Routine at a time, each enabled by the owner. OpenShell stays planned for later.
- They run on remote servers and workers, never the development machine. Remote workers are in the first version: one control Node runs Sessions on other servers (MN1's execution-only worker and a minimal placement slice; the transport is pending an owner decision).
- Harness authentication on a worker belongs to the user: the user logs Claude Code and Codex in on each worker through the harness's own login, and Maia detects and uses it but never reads, stores or moves the credential.
- Worker enrollment authority (v1; the option the owner selected): the
D-136 maintenance-window terminal ceremony enrolls or widens a
worker, with a preview-bound YES on
/dev/tty. "Revoke or narrow use the normal confirm. A managed admin credential replaces it later, with no data migration." - Attribution amends D-138 for the first version only. Enroll and
widen are attributed to the local principal the Node binds server-side
from the OS account that owns the profile, authenticated by the D-136
ceremony: the first owner's User, client and Device rows, created on the
Control Node host. Revoke and narrow are attributed to the local operator
bearer (
actor_local_user), a recorded exception to D-138, until the managed administrator credential replaces it. - Artifact transfer: streaming
artifact.putandartifact.getwith a digest and size bound, not JSON chunks. - Control Node OS (owner: "all possible, but start with linux"; the option
the owner selected): the first version's control Node runs on Linux, with
a suspend-aware monotonic clock
(
/proc/uptime,boot_id, tripwires). A macOS control Node with a suspend-aware clock, and other operating systems, are Planned. A clock that stops on sleep is never allowed. - Order: the lab production scenario, then a short soak of the same Routines on Maia's own repository (real GitHub, Maia's own code), then the first production setup, then the second. This replaces D-117's wait for SH2: the cutover no longer waits for it, and SH2 stays in the roadmap unchanged as a later milestone.
- SH0 (owner direction): it runs as soon as possible on a server, on a Node driven through the nightly CLI that replaces the current agent platform there, and agent work on this repository moves onto it.
- Run caps are in the first version: a minimal S65 sets caps on wall time, turns, tokens and cost.
- Commit identity is a per-Workspace setting. The first version supports a
named person's identity and signing key held by the control Node (owner:
"commits under my identity this is my config, in the
future we will let user configure as he wants"); other modes, such as the
GitHub App's own identity, come later. Commit identity, signing
requirement, merge method and pull request policy are Workspace and
Project rules and Skills in
.maia; the Node enforces them and fails closed. - Commit signing key (owner): the user provides it. Maia discovers the available keys and offers to create one; the private key moves only to the control Node's secret store.
- The GitHub App key, the commit signing key and installation tokens stay in the control Node. In the first version no Session of any kind runs on its host, neither a worker nor a coordinator, so S61a no longer waits for S47b on the cutover path.
- Why: owner direction: reach production faster; OpenShell can follow.
- Rejected: keeping the cutover behind S47a and S47b (D-116) or behind SH2 (D-117); a first version without remote workers; JSON-chunk artifact transfer; a control clock that stops on sleep.
- Consequences: the roadmap's first-version section holds the slices. S47 stays planned; moving a Session between workers is planned once it ships, and the owner wants it easy. Linux key custody needs E2a's systemd credentials backend.
- Links: first version, production migration, D-116, D-117, D-136, D-138.
D-151 A Session starts and steers only its own children, within bounds (2026-10-23) — status: accepted
- Decision (owner, 23 October 2026: "Yes, bounded"): amends S18. A Session may start and steer only its own children, to a configurable, bounded depth with a small default, within the Connection's session cap (D-097), with every child receipted and stopped when done.
- Why: a dispatcher Routine starts one child Session per issue without a person in the loop; the S18b guards already bound depth, fan-out and rate.
- Rejected: children of another Session; unbounded depth; children beyond the Connection's cap.
- Consequences: S18b guards, grants and the Run ledger apply to each child as they do to a delegated one.
- Links: S18, coordination, first version.
D-152 Sessions of one Workspace message each other by exact Session ID (2026-10-23) — status: accepted, amended by D-166
- Decision (owner, 23 October 2026): amends the "parent-mediated" rule in coordination. Any Session in a Workspace may message another Session of the same Workspace by exact Session ID, never across Workspaces. Waking the target is harness-agnostic: a Node-side baseline that works for any harness, plus optional harness-native enhancements, because Maia is the meta-harness that lets any harness work with another. The wake design is pending; Codex hooks stay disabled.
- Why: reviewers, implementers and dispatchers are siblings, not only parents and children; Claude Code and Codex each have their own internal mechanism, and neither reaches the other.
- Rejected: a parent-only relay; messaging across Workspaces; one harness's internal mechanism as the baseline.
- Consequences: S17 keeps the Node-stamped sender, the durable per-inbox order and receipts, and peer content stays data, never consent.
- Links: S17, first version.
D-147 Connection grants and pools are Node records changed by preview and confirmation (2026-10-23) — status: accepted
- Decision: a Connection reaches a Session only through a grant to the
Session's Project or its Workspace. A grant and a pool are Node records
(migration v068), each changed by preview, explicit confirmation,
compare-and-set and a receipt of its own. The scope is exact: a Project, or
an organizational Workspace (
container_…today,--containerin the CLI). Fail closed: an account no grant offers is refused at Session admission withconfiguration_invalidnamingmaia connection grant set. - Pools: one pool per scope in mode
off,single(one member) orrotate(1–32 members). Members are Connections already granted to that scope. A Project pool overrides its Workspace pool (D-022);offdefers to the next scope.rotatereuses D-021's order on the pool's members, at Session admission only, so one Project exceeds two concurrent Sessions per provider by spreading over accounts, each within its own cap. A named--connectionbypasses the pool but still needs a grant. Maia never rewritesauth.jsonor any harness credential file. - Revocation: admission, launch or recovery, and queued-turn dispatch each
re-read the grant. A Session on an account no grant offers its Project fails
closed: its activation fails and its queued turns fail as
turn_rejected. The dispatch check shares one synchronous storage call with the claim, so a revoke never falls between them. A turn already claimed (dispatching) or running finishes, because the validation after the claim is asynchronous. So does a launch past its check: launch preparation is asynchronous and the runtime lies outside the Node's database, so a revoke can still land between that check and the spawn.maia session stopends a live runtime. - Preview retries: a command ID with a pending preview belongs to that
actor, operation and normalized input. An identical retry gets a fresh proof
for the original expiry and retires the earlier proof, because storage holds
proof digests only; any other request on that command ID is
storage_conflict. A retry adds no row and never extends the window. - Why: several accounts per Project need an owner-approved offer, and a
Session must not reach an account outside it. Grants and pools are
runtime authority, so they stay in the Node, not in
.maia. - Rejected: a global default account; a typed
not_grantederror (needs a protocol error code for one message); a deletable grant or pool (a revoke keeps its row, so revision counts stay unambiguous: odd is active); a mutableallscope; gating Pi Sessions (they have no Connection). - Upgrade backfill (owner decision, 2026-10-23): v068 grants every
existing Connection to the Node's Workspace when the Node has exactly one, so
a single-org Node keeps its behavior. With none or two or more Workspaces it
grants nothing, and Sessions fail closed until an operator grants accounts
with
maia connection grant set; the refusal names that command. On a multi-org Node, a grant to every Workspace would let one org spend another org's account. Registering a Project creates its own Workspace, so a Node with several Projects usually gets no backfill. A new Connection or Workspace needs an explicit grant either way. - Choices the owner may reverse:
- A revoke is refused while the account is in a pool of that scope; a no-op request is refused rather than recorded.
offkeeps its row, so a compare-and-set never meets a recreated pool.- The executors doc line "every selection and skip is an event" stays
deferred: it needs a
session.createdschema change.
- CLI size pins: first measured on
7ddfdf09plus S2 (standalone 361,640 B, pack 463,847 B, help 70,248 B), then re-measured after mergingmainwith its later slices, in a release-style build withMAIA_RELEASE_VERSION=0.1.0-nightly.20261024.1: standalone 376,576 B, so the ceiling goes 366→368 KiB; pack 503,126 B, 4,212 B past the approved total's trigger, so the packed approval grows by 4,250 B; help (maia help --json) 71,362 B with the nightly version, 706 B over the 69 KiB budget, so the budget goes to 70 KiB (71,680 B). S2's six nodes add about 1,764 B of that help, and S26'sturn queueandturn cancelleaves 681 B. - Consequences:
resolveConnectionChoiceis the one place that decides which accounts a Project may use; S49 adds an Agent's pool binding there when it needs one. S7 raises Session caps and S61a places work against these grants. The operations are Source candidate and release-disabled until qualified. - Links: contracts, CLI reference, account selection.
D-154 The CLI imports Effect AI by subpath, and its size pins follow the measured footprint (2026-10-24) — status: accepted
- Decision:
apps/cli/src/mcp/server.tsimportseffect/ai/McpProtocol,effect/ai/McpSchemaandeffect/ai/McpServerdirectly. The barreleffect/aire-exports every MCP protocol version, and the bundler cannot prune a namespace import, so the standalone carried five protocol versions whilemaia mcpserves one (2025-06-18). The CLI size pins drop to the new measured footprint: standalone ceiling 372 → 350 KiB, packed approval −16,000 B, and the current-product references re-measured. - Why: measured on
36db3fba, the change takes the standalone from 379,808 to 357,596 B (−22,212), the package from 474,309 to 458,205 packed bytes (−16,104) and 1,457,269 to 1,435,159 unpacked. A pin left at the old value hides the next regression; the next slice raises its own pin and names its decision. - Rejected: dropping the CLI's declaration files from the package (the
library entry
makeCliandrunCliis documented in the CLI reference, and the Node integration tests import its types); pruning the 112 declarations the entry cannot reach, about 13 KB gzipped, which needs a post-build closure step and a guard test; a lint rule against the barrel, since the tight ceiling fails on a regression of this size. - Consequences: what remains of
maia mcpis 131,715 minified bytes (46 modules); Effect'sMcpServermodule and the four modules only it imports (its HTTP router among them) are 35,977 of those. They stay while the CLI uses the official capability. - Links: CLI rules.
D-155 Linux managed secrets are systemd-creds --user files, one per secret (2026-10-23) — status: accepted
- Decision: the managed
SecretStore(D-139) gains a Linux backend, labelled Source candidate; qualification on the target host is pending.MAIA_SECRET_STORE=managedon a fresh Linux profile writes the marker{backend: "systemd-creds", directory, storeId, version: 1}(an absolutedirectoryof at most 512 bytes, counted in bytes and free of quotes, backslashes and control characters, checked before activation so a marker the Node writes is one a restart reads), and every later start follows it. A marker names one backend: a host without it fails closed, and a profile never moves between backends. Each secret is one file in<state>/secret-store/(mode 0700), named by the SHA-256 of its store-scoped account, so no secret name reaches the disk. The file holds the text that/usr/bin/systemd-creds --user --name=maia.secret.v1 --with-key=host encrypt - -returns for D-139's name-bound payload, andgetruns the matchingdecrypt.putseals first, then publishes with an exclusive temporary file, fsync,link(which never replaces), removal of the temporary name and a directory fsync, and reportsstoredorkeptafter that sync. A name already taken ends the same way: the temporary name goes and the directory syncs before the call answers, because the earlier attempt may have linked the file and failed its sync. The name is then opened, decrypted and validated beforekept; an invalid file stays and the call fails. The published name must hold the inode the publication wrote. A mismatch (a file swapped in under the temporary name, or the directory pathname swapped after the link) fails the call asunsafeand removes no final name, because the name may hold a secret this call never created. The file left there gets the checks of any file found under a name: a laterputorgetrejects a malformed file or one sealed for another account, and accepts a valid copy sealed for the same account, so a laterputcan returnkept. Invalid content blocks publication for that account until the operator recovers the file or enters the secret under a new Connection name. A temporary name in a swapped directory can still go with the cleanup; the stable-path requirement below covers that. Reads open withO_NOFOLLOW | O_NONBLOCKand then check the descriptor: a regular file of this user, mode 0600, one link, at most 4096 bytes. One lock per store serializes publications and reads, because between the link and the removal of the temporary name a file has two links and a read would refuse it. Every call opens the directory withO_DIRECTORY | O_NOFOLLOW | O_NONBLOCK, checks the descriptor (a directory of this user that no other user can enter), compares the pathname's device and inode with it right before the link and before a read, and syncs through the descriptor, never through a pathname, so a swapped-in link or FIFO can neither redirect nor block the sync. Node has noopenatorlinkat, so the file names still resolve through the pathname per call; a swap inside the gap after the comparison needs the user's own access, which the custody claim below already excludes. Until the operations are descriptor-relative, the store directory and its parents must be stable paths that the user running the Node controls; the profile's state directory is one. Opening a profile removes the temporary files a crash left. Activation is the profile's first use: it checks--version(major 256 or later, parsed from text such assystemd 259 (259.9-0ubuntu0.1)) and one 16-byte seal and open before the marker lands, and a failure leaves no marker. A restart runs no tool, so a stopped credentials service cannot stop the Node; the launches that need a secret fail alone. The process manager admits/usr/bin/systemd-credsfor thesecret-storeowner alone, in thehard_containmenttier, with exactly three argument lists (--version, and theencryptanddecryptabove), an empty environment, piped stdin and working directory/. The tool passes the checker system Git passes: owned by root or the current user, writable by no other user (a private group may write), and several links allowed for a root-owned file. The layer treats the tool as optional: a host without it, or one the policy refuses, still starts the Node, and the opt-in managed store alone fails (amends D-018). - Why: the first version's control Node runs on Linux and needs custody of
the gateway key, the GitHub App key and the owner signing key (E2b).
systemd-credsships with systemd, adds no dependency and needs no helper. - Rejected:
--with-key=auto, which picks a TPM key where one exists whiletpm2fails in user scope, sohostkeeps one meaning on every host; omitting--name, which turns the tool's name check off; Keychain-style machinery for an uncertain write, becauseputcreates and a lost reply leaves no file; Maia-owned encryption or key files. - Consequences: this is OS-account custody (D-013). The user key
binds the uid, the username and the machine ID, so another uid cannot open
a file. Root reads the host key and is outside that claim, and any process
of the same user can run the tool. A worker still receives the key in its
environment at launch (D-139). The files do not move: a profile
copied to another host, or a lost or replaced host key
(
/var/lib/systemd/credential.secret), machine ID, uid or username, leaves every secretrecovery-required. Reads fail with a repair message and the owner enters the key again under a new Connection name. A backup of the profile alone restores no secret. A package update that replaces/usr/bin/systemd-credsneeds a Node restart, because the Node pins the tool's digest at start. Deleting the marker is not recovery. A systemd older than 256, or no tool, fails activation with a typed error. The backend stays Source candidate until the target host (Ubuntu 26.04.1, systemd 259, no TPM) passes the contract through the dogfood Node. - Evidence: the real-tool test (
systemd-creds.integration.test.ts) runs the shared contract throughsystemdCredsStoreFor, the Node's process manager and the built Linux helper, as an unprivileged user. It reports NOT RUN off Linux x64, as root, without/usr/bin/ccor/usr/bin/git(the helper build needs both), on a systemd older than 256 and outside a cgroup v2 subtree delegated to the user (an SSH login session, for example; run it undersystemd-run --user --scope), where the helper refuses every launch withcgroup_delegation_unavailable(D-019). It has not run on any host: CI's Linux runner has systemd 255, and the lab VM is arm64 and has no Node. The x64 limit is essential, not incidental: the process manager has a platform row, a vouchedsystemd-credsand Git, and a helper build only for linux-x64, so one qualification run waits for the target host through the dogfood Node. The lab VM did run the three argument lists through the native helper (3 of 3 passed, Landlock ABI 6). The lab facts come from Debian 13 (kernel 6.12.95, systemd 257.13, no TPM), run as an unprivileged user with an empty environment: the first--user encryptcreated the host key (root:root, mode 0400) and returned base64 text; a decrypt with--name=otherfailed with "Name in credential doesn't match expectations"; a decrypt without--namesucceeded with a warning; a flipped or truncated credential failed with "Bad credential format";encryptwithout--userfailed withInteractiveAuthenticationRequired;--with-key=tpm2failed in user scope; a second uid (nobody) could not open the first uid's credential, nor the first uid the second's; the largest payload (2081 bytes) sealed to 2962 bytes, inside the 4096-byte file bound. - Lint policy:
credential-files.tsjoined the "Secure filesystem" allow-list inscripts/eslint/effect-boundaries/policy/policy.mjs: it needs thenode:fsno-follow opens (credential and directory) andlstat, which Effect's FileSystem lacks. The directory opener sits behind aContext.Referenceso a test can fail a directory sync. - Links: operations, D-122.
D-160 The CLI standalone ceiling rises to 351 KiB for the exit module and the human output cleanup (2026-10-24) — status: accepted
- Decision:
STANDALONE_CEILING_BYTESinapps/cli/src/bin/maia.test.tsrises from 350 to 351 KiB (359,424 bytes). - Why: each change fit the 350 KiB ceiling (D-154) alone. Merged
on
7cfb285a, the bootstrap exit module (#72) and the labeled human output (#74) take the standalone to 358,516 bytes, 116 past it. The package packs to 460,492 bytes, inside its reviewed approval. - Rejected: trimming source to fit. The standalone payload is compressed, so a one-line edit moves its size by hundreds of bytes either way.
- Links: CLI rules.
D-161 maia dashboard is a terminal operator view over every registered Node (2026-10-24) — status: accepted, amended by D-175
- Decision (owner, 23 and 24 October 2026): Planned.
maia dashboard [--plain]is a CLIcompositeleaf over bounded, paginated reads of every registered Node (D-141). It reads and acts only through@maiaos/sdk, by way of@maiaos/client-core's cockpit model, attention order, Node merging, command registry and keymap store, as Desktop does. It offers no operation the CLI lacks and holds no authority. Agents and Routines come first; a Session's chat is the drill-down.- Two displays, the Tree (Workspaces, Projects, then Agents, Routines
and Sessions) and the Session view, with the approval panel, launcher and
key list as overlays. Normal rows use the terminal's default foreground;
only alarms and warnings have color. State codes have at most four
characters on a fixed 80-column grid, and Node text passes
cell()orblock(). Every action prints its CLI equivalent first, in the stdin form so no body appears, and its receipt ID after. - Renderer:
@earendil-works/pi-tuiat the exact version 1.0.0 through the pnpm catalog, external in the standalone like@maiaos/agent, loaded by dynamic import. A missing package exits 70 withnext: reinstall Maia. - Terminal views now, the json-render catalog later. Desktop stopped
rendering catalog views in
70dcc02f, so the dashboard writes terminal views. This amends D-052 for the terminal client until R9a migrates both clients. @maiaos/client-coreis to be published as the sixth npm package (D1-prep P4), prerelease only and external in the CLI, forapps/clientand the CLI. The owner published the placeholder0.0.0-canary.placeholder.1and configured its trusted publisher by hand, as for each other package's first version. D-134 avoided a new package name; this one takes one because two clients share the package.- Grammar and exits: global flags keep their meaning, and there is no
--workspace. The interactive mode needs a TTY on stdin and stdout, else it exits 64 naming--plain.--plainprints one Tree snapshot, reads no input and offers no action. UnderMAIA_SESSION_CREDENTIALit exits 77 before any request, in every mode. Quit exits 0; a signal exits 130. Both exit 76 instead, with one recovery line per command, when a mutation's outcome is unknown. - One keymap for everyone, with
:and Ctrl-k opening the launcher, and no presets or setup screen. At each start a read-only check, which never delays start, finds the terminal multiplexer the dashboard runs inside. It hides that multiplexer's reserved chords from the hints and refuses an override that binds one. Inside tmux those are the prefixes thattmux show-optionsreports; where a prefix cannot be read, the hints put:first. Nothing is written. Overrides live in$MAIA_HOME/dashboard-keys.json, keyed by command ID, and a bad file is never half-applied. - Approvals happen only in the panel, as two key events, after the request's last line has been on screen for 750 ms. No accelerator approves, as on Desktop (UI, D-076).
- Two displays, the Tree (Workspaces, Projects, then Agents, Routines
and Sessions) and the Session view, with the approval panel, launcher and
key list as overlays. Normal rows use the terminal's default foreground;
only alarms and warnings have color. State codes have at most four
characters on a fixed 80-column grid, and Node text passes
- Why: the owner runs agent work across harnesses and servers and wants
to manage workflows, not chats, from the terminal without a second backend.
Sharing client-core keeps one cockpit model for Desktop and the terminal.
pi-tui is the official renderer that
pi-coding-agentalready resolves at 1.0.0. - Rejected: the name
maia agents(S49's noun); bundling client-core into the standalone, which the owner declined in favor of publishing it; json-render in the CLI now; keymap presets or a setup screen (a tmux preset cannot be reached inside tmux, and a setup screen can commit by accident); reading multiplexer configuration files. - Consequences: CLI rule 6 admits bodies in reads made to show content
(contract rules). The renderer adapter
(D1a0) raises the standalone ceiling once, with measured growth: the
dashboard scope takes the standalone from 358,496 to 360,700 bytes (the
ceiling rises from 351 to 353 KiB, 361,472 bytes) and the package from
460,482 to 465,276 packed bytes (465 to 477 entries, 1,443,995 to 1,460,593
unpacked). Every "from" figure is the tree without the dashboard at
da4b38e7, the base of the D1a0 branch; main's7ed6a423changes none of the CLI's inputs (apps/cli, the protocol, SDK and agent packages), so the figures hold there. D-160 measured 358,516 and 460,492 on its own base,7cfb285a;da4b38e7follows it with SDK and status changes, and its figures are 20 and 10 bytes lower. pi-tui is external, so the standalone holds none of it, and installing grows by nothing:@maiaos/agentalready resolves the same@earendil-works/pi-tui@1.0.0throughpi-coding-agent. The work is a parallel lane that never blocks FV1–FV11 or the cutover; slices D0 to D2c build it (Phase 5). - Links: CLI rules, presentation runtime.
D-162 Before S69, a person sets a Session goal through the Node, which sends the harness's native goal (2026-10-24) — status: accepted
- Decision (owner, 23 October 2026): Planned (G1). G1 extends the
planned
session.goal.*contract (CLI reference):session.goal.showis that contract's read, andsession.goal.setandsession.goal.clearare person-only forms. The Node sends the Session's harness its native goal (Claude Code/goal, Codex goals through its app-server goal API); a client never types a goal into a turn. The harness's verdict stays advisory (D-123). The Node refuses a goal for a Session that no FV11 run cap bounds, so a native goal loop always ends. Each harness is supported only after its own proof. - Why: the dashboard and the CLI need goals before S69's Maia-owned loop, and each harness already runs its native goal. Only a person changes a goal, as S69 requires.
- Rejected: a client typing
/goalinto a turn, which bypasses the Node and leaves no receipt; waiting for S69. - Consequences: G1 follows FV11's run caps and precedes S69, which adds its check and budget to the same operations and still owns Node-sent continuations where a harness has no native goal.
- Links: agents and routines.
D-163 Work that cannot start waits in a visible queue in the run ledger (2026-10-24) — status: accepted
- Decision (owner, 23 October 2026): Planned (S60a). Work that cannot
start yet becomes a
queuedrun ledger row (D-119) that names its limiting resource (Node slots, an account or Agent cap, a run cap, a budget, a hold or a busy home Session) and its wait time. Ordering, aging and bounds follow the scheduler contract, which already returnsblockedrather than growing a queue silently; an entry that waits past its deadline shows asexpired, and a fire still launches at most once. A Routine's cron gainsif-busy: queue | skip;skipis today's overlap rule. This amends D-142: a fire that finds the Node full waits in the queue instead of failingsaturatedat its 10-minute deadline. The same owner decision sets three shapes, each in its owning slice: a cron'ssession: new | sameis Routinetarget.modestart | wake, in the Routine slice that shipswakeafter S26; an Agent'ssessions: single | multipleneeds a Node-enforced exclusive cardinality, with no slice yet, after S49, and no client showssinglebefore the Node enforces it; Routine runs show as sub-trees with their full paged history once S50 links fires to runs. - Why: a person needs to see why work waits and for how long. A fire that
fails
saturatedat a deadline hides the wait and drops the work. - Rejected: retrying in each client. D-142 rejected a queue of overlapping fires because it grows without bound during an outage; the scheduler's bounds and expiry bound this one.
- Consequences: until S60a ships, D-142's rule stands, and the dashboard
shows queued turns only inside the Session view; its
QUEandWAITcodes arrive with S60a. - Links: agent operations, D-161.
D-166 Message reach is a Project setting: the Project by default, the Workspace when both Projects allow it (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (S17). Amends
D-152 and D-092.
- By default a Session reaches every live Session of its Project, whichever checkout (Execution Environment) or worker process runs it.
- A Project's guard gains
messageReach:project(the default) orworkspace. Two Projects of one organizational Workspace reach each other only when both setworkspace. A person changes it withmaia project guard set(preview, confirmation, compare-and-set, receipt), as with the limits. Consent from both Projects is a condition on top of every other check, never an authorization by itself. - Reach is a bounded peer directory and nothing more. For a Session
credential,
session.listand message delivery use the same reach, and a listed peer shows only its Session ID, Project, role and state. Delivery also checks the recipient's state and the guards. History, content, memory, tools and credentials keep their own grants. Operator, coordinator and historical lists are unchanged (D-083). - Lookups run inside the authorization scope, so an unknown recipient and an unreachable one fail alike, take the same path and change no count or cursor. Negative tests prove it.
- Later tiers are directions, and each needs its own authorization and
revocation contract:
- Links between two exact Workspaces, never
all, approved by a person who administers both, after the ORG roles. - Execution Nodes of one Control Node share its store, so these tiers apply there unchanged (MN1).
- Independent Nodes exchange work as A2A tasks (D-006), never through each other's mailboxes.
- Links between two exact Workspaces, never
- These stay the same: exact Session IDs, no broadcast (D-030), the Node-stamped sender, untrusted peer content and the per-sender rate guard.
- Why: the source candidate reaches only the sender's own checkout. A
coding Session runs in its own worktree, so siblings can't message each
other, and neither can an orchestrator and its worktree children, even
though
session.listshows them. The owner wants a Project's Sessions to see and reach each other by default, and to control wider reach. - Rejected: keeping the checkout scope; consent from the recipient's
Project alone (a sender's Project could not keep its Sessions in); a
Node-wide or
allsetting. - Consequences: the mailbox checks the recipient with the reach rule instead of the sender's checkout. A stored message names both checkouts and, under D-168, keeps no content after transit. The swarm ownership table and acceptance gates carry the peer-directory exception. The worker Skill and the mailbox tool say "your Project" once this ships.
- Links: S17, sessions across servers, durable messages.
D-167 Agents read the Node's resources and environment through one read (2026-10-27) — status: accepted, amended by D-171
-
Decision (owner, 27 October 2026): Planned (S15, slice 1).
node.resources.show(maia.node-resources/v1) is a read with no receipt. It reports:- environment: Maia version, operating system, architecture, containment mode and the executors the Node can launch;
- host: CPU count, load average (POSIX), CPU busy share since the previous sample, memory total and available, and the Node service's cgroup limits when set. These host-wide figures are visible to every caller on the Node, which shares the host;
- disks: total and free bytes for the Node data root, the temporary directory and the caller's Project checkouts, one entry per filesystem;
- capacity: process-tree admission, and the free slots of the Connections the caller may use (no other Connection appears);
- Sessions: what the process-tree sampler measures (CPU time, memory where the platform measures it, RSS on macOS and cgroup memory on Linux, and process count) for each Session the caller may observe: its own Session, an orchestrator's own children, and every Session for an operator. Message reach (D-166) is not an observation grant, so a peer in reach shows no usage.
Each value is measured or absent with a reason, never a fabricated zero, and each response states its sample's age. A read samples on demand, and reads within 2 seconds reuse the sample. Only measurements are reused, never authorization, and admission still decides atomically. A Session credential gets disk roles with no path; no label or reason text reveals one either.
Surfaces:
maia node resources(with--nodefor any registered Node), the worker MCP toolshow_node_resourcesin both roles, and later the dashboard's Servers view. Execution Nodes send the same record in their heartbeat (MN), so placement reads it. -
Why: agents plan parallel work and choose a Node without knowing the host's free CPU, memory or disk. The Node already samples each worker's process tree but offers no host read.
-
Rejected: a resource summary in every Session's instructions (it goes stale and costs tokens every turn); a background sampler with history in this slice (history belongs to T2 and S59); a third-party metrics agent.
-
Consequences: admission thresholds, per-Session disk use and fleet history follow as their own S15 and S59 slices. The
show_node_resourcesline needs room in the worker Skill, which is at its 2 KiB cap: cut other text or raise the cap after review. -
Links: S15, shared resources.
D-168 Maia traces everything and keeps no copy of conversations (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (S12t). Amends
D-077, D-128, D-045 and D-046.
-
Scope. Harness Session conversations: turn prompts and responses, items and tool results, peer messages, and questions and approvals with their answers. Separately consented voice transcripts (D-048), other artifact classes and content another contract owns keep their own rules.
-
The trace. For every turn, item, tool call, message, question, approval and receipt, the Node keeps:
- IDs, actors, sender and recipient;
- kind, state and outcome;
- times, sizes and usage;
- a digest of the content in a canonical form;
- a native reference (harness session and item), when one exists.
That is what "fully trace anything" means: while the trace is kept (until S12r purges it when the Session is archived or 30 days after its last activity, D-128, longer under a legal hold), Maia can show what happened, who did it, in what order and with what outcome, and can prove a claim about content by its digest. Receipts keep their own retention after that. The trace and content classification names each field as trace, content held in transit, or never stored. A field it does not name, a new extension field included, counts as content until a reviewed change classifies it.
-
Content. A harness's own files hold the only lasting copy. Content reads go through the harness's reader (D-078), check the digest and mask on read. When a harness deletes or compacts its history, that content is lost: the trace stays for its retention, and a read reports
content_unavailable. -
Transit. The Node holds content only while a delivery is open: a turn's prompt, a peer message, or a question or approval and its answer. It drops the content once both of these hold:
- the consumer has it: the harness accepted the turn, the recipient acknowledged the message, or the requester accepted the resolution;
- the harness's reader returns it with a matching digest.
Until then the content stays held, so a crash of the Node or of the harness process loses nothing. A matching read shows that the harness's file holds the content, not that it reached the disk: a power loss before the harness flushes it can still lose it. S12t uses an executor's durable-commit signal where one exists and states the window for each executor. An expired, dead-lettered or cancelled delivery drops its content when it reaches that state. When no harness can return the content (a
record-onlyquestion, or no native reference), the consumer's contract holds it until the content is consumed or the delivery is abandoned. -
Content another contract owns stays under that contract. A typed decision that an operation consumes, such as an option ID or an approval verdict, is written into that operation's record. Run ledger objectives and settlement evidence, evaluation verdicts and datasets (S62, S68), Agent definitions, Routine templates, Project notes and Session titles keep their own bounds and retention. A Session's default title is the first line of its first prompt, cut to 80 characters: the one prompt excerpt the Node keeps, and a person can rename it.
-
Receipts keep their identity, request digest, authorization and an outcome without content. A replayed command returns that content-free projection: a turn's IDs, state, digests and native reference, with its prompt only while its delivery is open, so a turn result's prompt becomes optional. A duplicate answer compares the request digest and the decision its operation recorded, never a stored snapshot. Each content kind (prompt, response, each item kind, message, answer) has its own digest domain, and a digest covers the full content, not a truncated record.
-
Full transcripts and live streams stay available. A person or an authorized coordinator can load a Session's full transcript on demand: every message, tool call, result and change, page by page, read from the harness and checked against the trace. Attached viewers still receive every delta and item live through the live lane (D-077), in memory only and under the same disclosure policy.
-
- Why: the owner: "not the role of maia to save any transcript, they stay in their harness files" and "maia should be able to fully trace anything". A second copy becomes a second source of truth for personal content and keeps unredacted command output at rest (D-077's accepted risk).
- Rejected: keeping content for 30 days (D-128); holding transit content in memory only (a restart drops queued work); delivering only to a live, idle recipient (work for a busy Session is refused); a Maia copy of transcripts for legal hold (another transcript store).
- Consequences:
- S12t inventories every field that holds conversation content: Session stream records, turns, Session and orchestration messages, orchestration questions, interactions and their resolutions, worker approvals and command result snapshots.
- S12t replaces the turn snapshots that
turn.sendand interrupt receipts store, and the snapshot an interaction's replay validates, with the content-free projection; an interrupt receipt keeps its recovery flags. - S12t switches one executor at a time, once that executor's reader qualifies: a stable reference per item, bounded reads, and deterministic reconstruction that matches the digest. An item kind the reader cannot return is marked as such. Until an executor qualifies, its records keep today's durable lane and are purged under D-128.
- The migration that purges stored content keeps whatever is still in transit, runs with a preview and a receipt, and spares anything under a legal hold once ORG6 exists.
- An operation that needs content checks for it at the time of use: a
context rebuild, a fork, a harness switch, an evaluator, a Routine check,
a search. Missing required content refuses with
content_unavailable; missing optional content is reported. - S12t brings forward S65's masking of key values and key-shaped strings for every content read.
- Maia cannot retain or hold native transcripts. A legal hold covers the trace and the content in transit; holding a harness's own files needs that harness's support, which none offers today.
- D-128's 30 days now apply to trace records.
- The planned telemetry
contentlevel is dropped (D-045, D-046): telemetry keeps metadata, measurements and outcomes, never conversation content, on every release channel.
- Links: live Session item stream, S12, D-078.
D-169 Transactions between people and agents are proven by a signed hash chain, not a blockchain (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (E2). The threat it answers: someone who can
write the Node's database files, but does not hold its signing key,
changes, deletes or rolls back records. A stolen signing key or a
malicious Node is out of scope.
- Chain. Each receipt, event and trace record (D-168) gets a commitment: its sequence, kind, the digest of its payload and the digest of the commitment before it. Commitments are append-only and outlive the payloads they cover.
- Checkpoints. Each hour, at shutdown and before a backup, the Node signs a checkpoint (sequence and chain head) with a key from the managed secret store (E2a). It keeps the latest checkpoint outside the database as well: next to the key, and on its Control Node for an Execution Node (MN).
- Sealed prefix, unsealed tail. Records up to the latest checkpoint are sealed; later ones are reported as unsealed, not proven. At startup the Node verifies the chain against the latest checkpoint before it signs again. A database behind that checkpoint (a rollback) or a chain that disagrees with it fails closed until a person confirms a restore (S63) or a fork, which is recorded with its own receipt.
- Purges. A purge (S12r, D-038) deletes payloads, never
commitments, and appends a record naming what it deleted and the receipt
that authorized it.
maia journal verifyreports each record as present and verified, deleted by an authorized purge, or missing unexpectedly, and names the first failure. - Attribution. A record proves what the Node attested: the actor it authenticated, the Node-stamped sender, the content digest and the recorded outcome of a confirmation, a peer message, a turn, an artifact or a file change (identified by its Git object or content digest). It is not a person's signature; participant signatures come only if independent non-repudiation becomes a requirement.
- Keyed digests. Content digests are HMAC-SHA-256 with a key per
Workspace, over D-168's canonical form and digest domain, and name their
algorithm, canonical-form version and key ID. A keyed digest reveals
only equality within its Workspace. Every content-derived digest is
keyed, today's
inputDigest,outputDigestandrequest_digestincluded, and an idempotent retry compares under the key that wrote its receipt. Rotation keeps old keys for verification only. With the key unavailable or erased, a content check reportsdigest_key_unavailableand a retry fails closed rather than starting new work. Erasing a Workspace's key is a Workspace-wide erasure under ORG6 that respects legal holds; the chain still verifies. - Across Nodes. Execution Nodes append to their Control Node's chain (MN). A receipt from another Node is that Node's signed assertion: the receiver verifies the signature, not the content. Countersigned checkpoints between independent Nodes, inclusion proofs for a third party and anchoring in an external transparency log come later, each with its own owner decision and an approved egress.
- Why: the owner asked whether blockchain or ledger technology can journal and guarantee transactions between people and agents. The properties needed are evidence of tampering and rollback, attribution the Node attests, and verification by another party. Hash chains, signatures and checkpoints kept outside the database provide them. A blockchain adds consensus among writers who distrust each other, and one authoritative Node has no such writers. Its other costs are latency, a network of other parties, fees or tokens, and permanent copies that erasure (ORG6) must be able to remove.
- Rejected: a public or permissioned blockchain; content on any ledger; a signature on every record (checkpoints sign many records at once); unkeyed content digests (anyone could confirm a guess at short content); countersigning between Nodes now (it needs peer identity, availability and fork contracts before a consumer exists).
- Consequences:
- It meets E2's acceptance as that entry now states it: changing or deleting a sealed audit record breaks verification at that record, and a database rolled back behind the latest checkpoint fails startup.
- Someone who can write the database can still rewrite the unsealed tail, at most one checkpoint interval of records.
- ORG5 searches and exports over the chain.
- A restored backup (S63) verifies against the checkpoint taken before
it, and the restore is a recorded fork.
- Implementation depends on the E2a key.
- Links: enterprise readiness, E2, D-168.
D-170 Maia manages Skills with the skills CLI on every harness and reports whether each is up to date (2026-10-27) — status: accepted, amended by D-193
- Decision (owner, 27 October 2026): Planned (S13). Amends
D-040, and D-039 for Skills: they are no longer
configured in
.maia. Skills are Maia's standard operating procedures (Skills).- Maia builds no Skill installer, lock or update checker. The pinned
skillsCLI installs, checks and updates Skills for every harness. - The Project's
skills-lock.json, which the CLI writes and the Project commits with the Skill folders, is the only Project pin; machine Skills use the CLI's global lock. Maia keeps no second manifest, and.maia/skillsand~/.maia/skillsleave the target layout. - Maia reads
skills list --jsonand the lock files and recomputes folder hashes with the CLI's algorithm. It never parses the CLI's text output. - Project and machine Skills load only from a lock; built-in, extension and learned Skills keep their release, extension or version digest. An unpinned Skill is a discovery candidate until the CLI adds it to the lock, which replaces D-040's trusted-Project fallback; resolution keeps the qualified identities of resolution and admission, with no shadowing.
skills.statusreports each Skill ascurrent,outdated,modified,missing,unpinnedorunchecked, for each Project, Node and harness, with the time of the last check and the Sessions still on an older digest.skills.checkruns the CLI's update in a scratch copy and compares the scratch lock with the real one. It never runs at launch.skills.update.previewandskills.update.confirmrun the CLI's update in a worktree, refuse when the new hashes differ from the preview, and leave the change for the Project's review with a receipt. Running Sessions keep the Skills they launched with.
- Maia builds no Skill installer, lock or update checker. The pinned
- Why: the owner: "maia should manage like and with the npx skills (do not reinvent) across harnesses, make sure versioning and all skills are up to date or not". The CLI already knows each harness's Skills folders, keeps a committed lock with folder hashes and checks sources for updates. A second manifest would drift from that lock.
- Rejected: a Maia manifest beside the lock (two pins drift); Maia-only Skill folders that no harness reads; parsing the CLI's text output; checking sources at each launch (the network on every launch); updating Skills under running Sessions.
- Consequences:
- D-040's release tests pin
skills list --json, both lock formats and the folder hash against the pinned CLI version. - Until R11 AG1 moves it to the lock, local
maia agentkeeps reading its pins from.maia/maia.json. - Project Skills need a launch route for Codex and Claude Code workers (FV4): D-105 turns Codex's own Skills list off, and the Claude Code worker profile reads no Skills folder (D-087).
- The CLI pin (1.7.0) moves only through a reviewed change.
- D-040's release tests pin
- Links: Skills, D-040, D-087, D-105, S13.
D-171 Maia onboards every supported harness's sessions on its own and claims them when they end (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (S11b). Amends D-078 and D-167. Maia is the one tool that manages every harness a person
uses, so sessions started outside Maia join it with no per-session step.
- Discovery runs on its own. The Node reads each supported harness
through D-078's readers (the Claude Agent SDK listing,
codex app-serverthread/list) at start, on a bounded schedule run as a built-in Routine (S30) and on demand (maia import scan). It adds no scheduler and never parses vendor files. - Every session but Maia's own. Interactive, background, scripted and SDK sessions join, each labelled by origin; subagents join under their parent. Sessions Maia launched are already Maia Sessions and are skipped.
- On by default, owner-only. Onboarding is on for a person's harnesses on their own Node. Each onboarded session is visible only to the person whose harness ran it until they share it into a team Workspace, a receipted action. Joining a Project does not share it: until its owner shares it, discovery, history, resource and peer-directory reads enforce that person's ownership, and neither operator status nor Project membership overrides it (an exception to D-167's operator view). Turning onboarding off stops the scans, removes the unchanged hook entries Maia wrote, reports edited ones through the drift flow and keeps the claims.
- Trace, not content. An onboarded session keeps metadata and the
trace D-168 classifies; titles, prompts and history stay in
the harness and are read through
session.history.read. - Projects. A session in a repository registered as a Project joins
that Project. Any other session waits in the person's Unassigned list
with a proposed Project; one confirmation registers the repository and
moves its sessions in. Maia never creates a Project or admits a
repository's
.maiaon its own. - Claimed when it ends. When the harness's evidence shows a session
ended (Claude's
sessions/<pid>.jsonis gone; for Codex, which keeps no liveness Maia can read, a hook the person trusted reports the end of the latest run with no observation gap since), Maia claims it as a stopped Maia Session owned by that person: once per executor and native ID (session_adoptions), with a receipt. Claiming needs the vendor's system-default Connection and a registered Project; until both exist the session stays observed. Nothing runs until someone resumes it, and every resume rechecks liveness and refuses withadoption_refusedwhile another client runs it. Missing hook trust, an observation gap or stale evidence leaves a Codex session's liveness unknown; claiming and resuming it then need a fresh attestation, and a scan never repairs that. A person can release a claim; the session then stays observed and is claimed again only when they resume it through Maia. - Live sessions are observed through hooks. S13 installs Maia's observation hook into each supported harness's user configuration as a managed item (manifest, digest, compare-and-set, removed when onboarding is off). A session a person starts in a vendor CLI then streams its trace to the Node while it runs: read-only, with no control until it ends. The hook fails open: a stopped or unreachable Node never blocks the harness; the next scan restores metadata, and the gap is recorded. Codex runs a hook only after the person trusts it and Maia never writes a trust hash, so S11b asks the person to trust its hook once. A hook in a session Maia launched sends nothing, so nothing is traced twice.
- Every harness. Each executor that passes the executor qualification ships its discovery source and hook: Claude Code and Codex first; Pi and Maia agent (S23), Cursor (S33), OpenCode (S34) and ACP agents (S31, S32) with their slices.
session.adopt.previewandsession.adopt.confirmare Deprecated once S11b ships; until thenmaia session adoptis the manual claim.
- Discovery runs on its own. The Node reads each supported harness
through D-078's readers (the Claude Agent SDK listing,
- Why: the owner: "when a user use maia, is to take fully control over all harness … i want a single tool that can manage them all", and "maia should be able to onboard automatically any claude, codex, any harness supported existing sessions". A preview and confirmation per session made onboarding a chore. The safety they gave stays: the liveness check at claim and resume keeps one controller per session, and D-168 keeps content in the harness.
- Rejected: driving or signalling a session another client runs (two
controllers; Maia never signals a process it did not launch, D-078);
sharing onboarded sessions with the Workspace by default (exposes a
person's session metadata without consent); creating Projects on its own
(admits a repository's
.maiawith no review); interactive sessions only (misses background and scripted work); claiming only at first resume (leaves ended sessions outside Routines, messages and lists); copying transcripts (D-078, D-168). - Consequences:
- A Codex scan appends to Codex's own logs (D-078), so the scheduled scan runs minutes apart, never seconds; S11b's contract sets the default.
- The observation hook is the one item S13 writes into a person's harness configuration by default; FV10's per-Session kit still leaves nothing there.
- Each origin (background agents, scripted runs, SDK sessions) is claimed as covered only after S11b shows its harness's reader lists it.
- A stale Claude pid record still reads as live and delays the claim (D-078).
- Links: S11, D-078, D-168, harness configuration, CLI.
D-172 Model tools follow the conventions agent SDKs share (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (TC1, then R11
AG2–AG3). Every model-facing tool Maia defines for agents (the
coordinator and worker MCP servers and the Maia agent catalogue) follows
the conventions current agent SDKs share, so a tool reads the same to a
model whichever harness runs it. The realtime voice tool keeps its
reviewed two-action shape.
- Names: snake_case, verb first, at most 64 characters, matching
^[a-z][a-z0-9_]*$(list_sessions,wait_turn,send_message). The protocol operation ID staysnoun.verb(session.list); each projection declares its tool name, and a mechanical check enforces the format, uniqueness and one operation per tool. Dotted names such asprocess.startare not valid tool names for the major model APIs. - Namespaces:
__separates a namespace from a tool. Maia's catalogue tools aremaia__…, a prefix nothing else may use; a Connection's or tool server's tools are<connection>__<tool>. An MCP client adds its own prefix, somaia mcpserves bare names. - Common built-ins: a Maia tool with the same meaning uses the common
name and parameters:
read_file,write_file,edit_file,glob,grep(pattern,path,glob,ignoreCase,literal,context,limit50 by default and at most 200,outputModefiles_with_matches,contentorcount, andtruncatedin the result),web_fetch(url),web_searchandask_question. A tool with other semantics keeps its own verb-first name: Maia's argv-onlyexecution.shell.runisrun_command, neverbash, because it runs no shell. Vendor harnesses keep their own built-in names; the trace maps each to its item kind. - Definition: a
descriptionwritten for the model (what the tool does and when to use it), aninputSchemain JSON Schema 2020-12 generated from the protocol schema, anoutputSchemawhere the result is typed, and optional fields left optional. - Results and errors: results are JSON and bounded, with
truncatedwhen cut; the model reads a short projection and automation reads the typed result (the model-tool envelope). Errors carry the protocol's lowercase snake codes (role_denied) withmessage,retryableand an optionalhintthe model can act on. A tool never retries on its own, and an external write carries an idempotency key. - Progress: a long call reports last-write-wins snapshots keyed by its
call ID and settles once,
completed,failed,cancelledorinterrupted. - Deferred tools: an activation with many tools lists the core ones;
maia__search({ query, limit })finds admitted tool-server tools,maia__tool({ name, input })calls one by its<connection>__<tool>name with the revision, digest and idempotency fields its catalogue row requires,list_skillslists eligible Skills andmaia__skill({ name })loads a Skill body. Schemas stay in the activation's context digest. - Already aligned, no rename: Skills use the Agent Skills format
through the
skillsCLI (D-170); events use lowercasefamily.verbwith camelCase fields.
- Names: snake_case, verb first, at most 64 characters, matching
- Why: the owner: "for tools and else we should maximise have the same conventions", naming an agent SDK whose tool conventions, by the owner's account, other agent SDKs share. A model that learned these names and shapes uses Maia's tools without extra instructions, and companies building on Maia write tools one way. Renaming now, while every tool surface is a source candidate or planned, costs least.
- Rejected: names derived from operation IDs (
session_events_listreads noun first, and dotted names fail model APIs); thebashname for an argv tool (one name, two meanings); amaia_prefix on MCP tools (the client namespaces them already); renaming events fromcompletedtosettled(churn with no gain for a model). - Consequences:
- TC1 renames the coordinator and worker MCP tools;
maia-orchestration, themaia-workerSkill (within its 2 KiB cap) and the voice orchestrator prompt follow, and definitions stay generated from the protocol registry. - The R11 model-tool catalogue
uses these names, parameters and error codes.
maia__toolis its one tool with two operations: the Node routes a call to the read or mutation operation that the target tool's admitted risk class names. - S47e's per-tool approval declaration takes the shared vocabulary once its semantics are verified against the source.
- TC1 renames the coordinator and worker MCP tools;
- Links: CLI reference, protocol, D-170.
D-173 Maia keeps each harness's context and tool calls low and proves it with measurements (2026-10-27) — status: accepted
- Decision (owner, 27 October 2026): Planned (CE1, S26). A harness
that Maia runs should spend fewer tokens and tool calls per task than the
same harness run on its own, and Maia shows that with numbers.
- Lean per call, already in source. Workers start with the user's MCP
servers, web search, Skill lists, plugin text and the Codex features that
run outside the profile turned off, with
settingSources: []for Claude Code and only their role's tools. The worker Skill body replaces Codex's Skill list, about 300 tokens a call instead of about 5,000 (harness configuration). This is the baseline every later change is measured against. - Wait, never poll. A coordinator waits for its children with one call
that ends when a waited turn settles, needs a person or reaches its
deadline, and the Node wakes a Session when a child settles or a message
arrives (S26, FV9). Today an orchestrator can only poll
turn_show, and an external coordinator'sturn_waitholds at most 4 seconds, so a five-minute child costs about 75 calls. - A fixed-context budget per role, checked with no model call: the instructions, Skill bodies and tool schemas each role and executor gets at launch, pinned like the worker Skill cap. Text one role needs never reaches another.
- Measured, not assumed. A lab suite records tokens (input, cached input, output, reasoning), model calls, tool calls and pass rate per task on a fixed task set, and replay evals cap the tool calls of Maia's own Skills (benchmarks). Reasoning effort, prompt-cache lifetime, compaction threshold and vendor instructions a role cannot use change per Agent only when a comparison shows fewer tokens or calls at an equal pass rate.
- Context stats. Each Session shows its model's window, the tokens in it and the share used, its fixed prefix and each compaction, from the harness's own report, with per-category counts where the harness gives them and never their text or paths (D-168).
- Lean per call, already in source. Workers start with the user's MCP
servers, web search, Skill lists, plugin text and the Codex features that
run outside the profile turned off, with
- Why: the owner: "when using maia, claude/codex harness uses fewer tools calls and fewer context consumption … how to ensure a even better behavior from maia". A reference GUI for the same two harnesses, studied on 27 October 2026, runs each harness with its full default context and adds its own tools; its gains come from orchestration instead: children wake their parent rather than being polled, evals cap tool calls on real models, idle threads compact before the prompt cache expires, and one model gets a lower default reasoning effort. Maia already sends less per call; polling is where it loses.
- Rejected: a global default for effort or model (a setting per Agent, adopted on evidence, keeps Maia from capping a harness); trimming a vendor's system prompt beyond what its SDK supports (breaks harness behavior across updates); model evals in CI (they spend model usage, and D-081 keeps real Claude turns out of tests).
- Consequences:
- The orchestrator paragraph leaves the workers' copy of the worker Skill
(about 60 tokens on every worker call).
- Lab runs reach models through the dev lab's endpoint, each approved by the owner.
- Both harnesses already report context: Codex's
thread/tokenUsage/updatedcarriesmodelContextWindow(its app-server schema, checked on 27 October 2026), and the pinned Claude Agent SDK 0.3.285 hasgetContextUsage, whose summary mode makes no token-count calls. Maia decodes neither today. - Claude settings that might cut fixed context (for example omitting
commit guidance for workers that cannot commit) are candidates for CE1c,
adopted only after the SDK honours them with
settingSources: []and a run shows the saving.
- The orchestrator paragraph leaves the workers' copy of the worker Skill
(about 60 tokens on every worker call).
- Links: S26, benchmarks, harness configuration, D-172.
D-174 Routine gates, sandboxed Project setup and Session moves to a server (2026-10-28) — status: accepted
- Decision (owner, 28 October 2026): three mechanics join existing slices;
none adds a slice.
- Routine gates (S50). A cron trigger may name a gate: a command a
person sets in the Routine's versioned definition, never one a model
writes. The gate names one exact admitted Agent definition, and the Node
runs it in a sandbox at that Agent's placement (S47b), with that Agent's
grants and network policy and nothing more, for at most 60 seconds and
64 KiB of output; the steps without
afterread its output. Exit 0 with no output ends the fire asskippedwith reasongate_empty, and no Session starts; exit 0 with output runs the step and passes the output to it as bounded, untrusted input that S12t masks; any other exit or a timeout fails the fire with a typed reason. A gated Routine may fire every minute instead of every 15, and its empty fires are counted per Routine and hour rather than stored as runs. The no-overlap rule is unchanged. - Sandboxed Project setup (S47b). The Project setup command
(D-075) runs inside the Session's sandbox before its first
turn, as the worker user under the Session's network policy, within
today's 15 minutes. A failed setup fails that launch, and the Session's
next launch runs it again, at most 3 attempts in all; the last 64 KiB of
its output stays with the setup outcome and is read through the
Session's trace, masked by S12t. The command is still recorded when the
worktree is created, so a worker's branch cannot plant one, and an edit
to
.maia/worktree.jsonreaches only new Sessions. - Session moves to a server (S66). A move runs in both directions between an Execution Node on a person's computer and a server's, so a person moves a Session before closing a laptop and back later; it is previewed, confirmed and receipted. Each harness qualifies carrying its native session on its own (Codex, Claude Code and Pi first), and the preview refuses a target Node without that harness version or the Session's Connection. An onboarded Session (D-171), once claimed, moves like any other.
- Routine gates (S50). A cron trigger may name a gate: a command a
person sets in the Routine's versioned definition, never one a model
writes. The gate names one exact admitted Agent definition, and the Node
runs it in a sandbox at that Agent's placement (S47b), with that Agent's
grants and network policy and nothing more, for at most 60 seconds and
64 KiB of output; the steps without
- Why: the owner chose these after comparing Maia with a hosted product that runs coding agents in cloud sandboxes (28 October 2026). A gate lets a Routine poll a source that has no S51 connector without paying for a model turn on each fire; S51 stays the push path. Running setup in the sandbox removes the reason the Node refuses setup commands today, so workers get the Project's toolchain. A move to a server keeps work going while a laptop sleeps, through S66's existing between-turns handoff.
- Rejected: running a gate unsandboxed on the Node host; letting a model author a gate; pushing an edited setup command to existing Sessions; migrating a live process instead of moving between turns.
- Consequences:
- D-075's refusal of setup commands outside tests holds until S47b runs them in the sandbox; Sessions without a sandbox keep it.
- Gates and sandboxed setup wait for S47b; gates also wait for S50.
- Links: Routines, S47, S66, coding Sessions, routine model.
D-175 Maia builds its own terminal multiplexer, in Rust, before the first version (2026-10-28) — status: accepted, amended by D-177, D-178, D-182 and D-183
- Decision (owner, 28 October 2026): Planned (TM0–TM11). Amends
D-161 and D-141.
- Direction. The owner manages Projects on several servers from the
maiaCLI and a terminal, not from Desktop. Maia builds its own terminal multiplexer, rather than driving tmux, and it replaces the terminal workspace manager the owner uses today. The milestone comes before FV1–FV11 on the critical path. It runs on D-141's independent Nodes over SSH, so it waits for neither the worker transport nor MN1. - What a person sees.
- A sidebar of Nodes, Workspaces and Projects. Each row rolls up what needs a person.
- Tabs, at least one per open Project.
- Panes split right or down. A pane shows either a Node terminal or a Maia Session view. A terminal runs whatever the person starts: a shell, a build, or Claude Code or Codex in its own interface.
- A person uses it for any terminal work, as with tmux.
- Keys: the prefix is Ctrl-b; pressing it twice sends Ctrl-b to the
program. After the prefix:
vand-split;hjklmove focus;zzooms andxcloses the pane;copens a tab and1–9select one;[enters copy mode;wlists Projects andgopens go-to;ddetaches and?lists the keys.
- Mouse: it selects, scrolls and resizes.
- Overrides live in D-161's file.
- Who drives it.
- People: keys, the mouse and
maia terminalcommands. - Agents: tools for the terminals they open themselves (TM7).
- An agent prompts another agent through Sessions (turn send and wait, S26; messages, S17), never by typing into its terminal.
- People: keys, the mouse and
- Where state lives.
- The Node owns each terminal's identity, Project, owner, controller lease and lifecycle. It records their metadata and receipts, never output.
- The Node's native process host, one helper per launched process, owns each PTY. A terminal's helper also runs its screen engine, in memory: the screen and a bounded scrollback (10,000 lines by default, set per Node). It answers snapshots, text reads and waits, and draws frames.
- Terminal output is never written to SQLite or anywhere else on disk.
A migration deletes the stored output (
terminal_live_chunks) and its limits (TM2a). Until the screen engine lands (TM2b), a bounded buffer in memory serves reads. A Node restart loses buffered output. A stream from a cursor the buffer no longer holds gets the existinggapevent (cursor_expired); TM2a freezes the same signal for reads. Cursors keep counting across a restart. - The client keeps only the person's layout, in
$MAIA_HOME. Closing the client, losing the SSH link or sleeping the computer stops nothing. Reopening rebuilds the layout and reattaches.
- Frames, not bytes.
- A client receives drawn frames, only for the panes it shows. A frame holds the changed rows, coalesced to the client's frame rate.
- A client gets a full frame when it starts and whenever it falls behind, and nothing for hidden panes. So the volume of a program's output never reaches the client.
- Pane output reaches the person's terminal only as cells the client draws. A pane cannot write the clipboard, pass sequences through or change the title, and its links open only on the person's action.
- The client writes the clipboard (OSC 52) only on the person's action.
- Footprint (TM0).
- The target: Maia's multiplexer uses less CPU, memory and disk than tmux, and than the current terminal workspace manager, on the same machine, base included.
- What counts: everything Maia runs for it: the client, each terminal's helper, and the Node with its base. A breakdown shows the Node's base on its own line and never subtracts it.
- Disk: total writes are compared with the other tools. Separately, a run fails if any terminal output is written. Metadata and receipts count in the totals.
- A benchmark measures this before the screen engine is picked, and again in every TM slice after.
- Rust.
- Every new native part is Rust, the language Desktop's shell already uses: the client, and the screen engine. The engine links into the existing C process host through a C ABI.
- The client is a native binary for each platform, shipped with the CLI.
maia terminal attachand the multiplexer start it. - It reaches Nodes through a Rust binding generated from the protocol
registry: the same operations, errors and transports as
@maiaos/sdk, checked by registry parity, and no business authority. TM4 freezes that binding. - The screen engine is picked from TM0's measurements of Rust-reachable engines the owner approves, against a headless baseline.
- Each crate needs the owner's approval when its slice adds it.
- Alpha 1. The owner's goal: from one terminal, a person and their
agents run Claude Code and Codex on several servers as if on one machine.
- They attach and detach from any computer.
- They manage cron Routines per Project, grouped by Workspace, without naming a server.
- Every action is a CLI command with JSON output, and a model tool unless it is human-only: a person's terminals. Routine mutations follow D-178.
- Alpha 1 is TM0–TM11 with S30 qualified. A Routine owned by a Workspace itself follows R3.
- One address space (TM11). When
--nodeis absent, an exact Workspace, Project, Session or Routine ID selects the one registered Node that holds it.- A mutation still reaches exactly one Node.
- An ID that two Nodes hold is refused, never guessed.
- A read of an exact ID selects one Node the same way. A list without
--nodeor an exact target merges every Node, and each row is labeled. --nodestays, to narrow a list or settle a refusal.
- Platforms.
- The client runs on macOS, Linux and Windows, on x64 and arm64.
- Live Node terminals are a macOS arm64 source candidate today.
- Linux follows in TM1a.
- Windows follows in TM1b, through ConPTY. This amends RL7's exclusion of a native Windows Node for terminals. RL7's Windows CLI joins TM10's prerequisites.
- Safety.
- A person's terminal:
- Only a person creates one, in a trusted client, as today.
- It runs as the Node's account with the person's authority, as an SSH login does.
- No agent can read or write it.
- It does not authorize agent work on that host. D-150's key custody holds: no Session, and so no agent terminal, runs on a control Node's host that keeps the keys.
- An agent's terminal belongs to its Session:
- It runs inside the Session's containment, at most 4 per Session by default, and ends with the Session.
- It appears in its Project as an unfocused pane, and a person can take its controller lease.
- S12t masks the agent's reads. Inputs and reads are traced without content (D-168).
- Every wait has a deadline. A wait that timed out does not prove its input was not delivered.
- A person's terminal:
- Direction. The owner manages Projects on several servers from the
- Why:
- The owner prefers a terminal to Desktop. They want one tool for people and agents across servers.
- tmux and the current terminal workspace manager keep their state outside Maia. They cannot apply Maia's grants or show Workspaces and Projects.
- Frames and an in-memory screen engine keep CPU, memory and disk low. However much a program prints, the client pays only for frames, and nothing is written per byte.
- Storing no output also removes the copy of an agent conversation that a native harness shows in a terminal (D-168).
- Rejected:
- tmux as the pane engine: its state and authority sit outside the Node.
- Raw bytes to clients: every client would need its own emulator, and a pane's escape sequences could reach the person's terminal.
- A TypeScript client. An idle Node.js process uses 43 MiB, while an idle tmux server here uses about 5 MiB (both observed on one Linux machine, 28 October 2026).
- Keeping terminal output on disk, even bounded.
- Plugins, user commands, screen-based agent detection and automatic rule updates in this milestone.
- Consequences:
- D-161, amended.
maia dashboardstays the TypeScript view of Sessions, approvals and the launcher. It opens as a pane, paying its Node.js memory only while open.- D1-prep, D1a0, D1a1, D1a2 and D2a–D2c join this milestone where it needs them.
- D1b and the later views stay in Phase 5.
- The multiplexer client joins the clients that reach the Node only through public operations, through the generated Rust binding (AGENTS.md).
- Work moves from other slices:
- FV8's
routine run→ TM9. - S14's streams over the bridge → TM3.
- S14's
node rename,node disableandenable→ TM9. - S21's ownership rules start with agent terminals (TM7).
- FV8's
- Linux needs an interactive terminal tier like macOS's, and a shell
allow-list in place of the
/bin/zshpin (TM1a). - Screen engine: the owner picks it from TM0's numbers.
- D-161, amended.
- Links: roadmap, benchmark, dashboard, trace without content.
D-176 A running Node can be registered with a CLI without installing it (2026-10-28) — status: accepted
- Decision:
maia node register <ssh-host>(node.remote.register) records a Maia Node that already runs on a host, so--node <name>reaches it like an installed one.- Preview. The local Node opens the SSH bridge with the ssh arguments
--nodeuses (BatchMode, strict host-key checking) and reads the greeting.--launchernames the bridge launcher; without it, the local Node reads the remoteHOMEonce over the same arguments and greets install's default layout,~/.maia/profiles/<profile>/bridge(profiledefaultunless--remote-profilenames one). The preview shows the absolute launcher, profile, instance and version. It refuses an untrusted host key and namesssh <host>as the remedy, a bridge that speaks another protocol, a profile other than--remote-profile(profile_mismatch), a name in use, and a host and launcher another record already holds, installed or registered (node_already_registered). The CLI refuses the namelocalwith TM11's shared check. - Confirm. The confirmation binds the inputs, the plan digest (host,
launcher, profile, instance and version), the actor, the registry
revision and a two-minute expiry. Apply greets the previewed launcher
again and refuses with
node_changedwhen another instance, profile or version answers. It records the Nodereadywith originregisteredand returns a receipt; a retry with the same command ID returns that receipt without reaching the host. - Lifecycle. A registered Node has no root, unit or pinned executors.
node.remote.removeforgets it and never reaches the host;--purgeandnode.remote.installunder its name refuse, andloginrefuses because nothing is pinned. - Exposure. Human-only, like install and remove. A person answers y/N;
a script pipes
--json --dry-runinto--confirm-stdin. There is no--yes.
- Preview. The local Node opens the SSH bridge with the ssh arguments
- Why: a Node a person started on a server (the dev lab, a host set up by
hand) was unreachable: the registry reached
readyonly through an install. Registering reuses the registry, the bridge and the two-phase confirmation, and adds no authority. - Rejected:
- A direct SQLite seed: it skips the preview, the confirmation and the receipt, and records a Node nobody greeted.
- Trusting the host key on first use: D-141 keeps strict host
keys; a person verifies the key with
ssh <host>first.
- Consequences:
- A migration lets a remote Node preview bind
node.remote.register.confirm. - Three error codes are new:
profile_mismatch,node_already_registeredandnode_changed. The other refusals reuse install'stransport_failedandstorage_conflict. - A registered record keeps its launcher in the registry's root column, which may not be empty; the launcher keys its live target as the root keys an installed one.
- The HOME read and the greeting share 4 seconds, so a preview answers inside one Node API request.
- The record keeps no instance ID. A restart between preview and apply
refuses with
node_changed; a later restart goes unnoticed. - The already-registered check compares the ssh host as written, so two aliases of one machine can register the same Node under two names.
- Desktop refuses a registered Node as an invalid record until its reader accepts a record without a root.
- A migration lets a remote Node preview bind
- Links: remote Node install, roadmap S14, CLI.
D-177 The terminal footprint is measured per terminal against tmux; the Node's base is its own line (2026-10-28) — status: accepted, amended by D-182
- Decision (owner, 28 October 2026): this amends D-175's footprint rule.
- Each terminal's and each pane's CPU, memory and disk must be below
tmux's in the same run.
- A terminal's cost is the scenario's total across every Maia process, minus the same Node idle with no terminal open for as long as the scenario runs.
- A pane's cost is that cost divided by the scenario's panes.
- The Node's base (idle, no terminal) is its own line,
terminal.node.base.bytes. It reportsnode.idle.memory.bytes, which holds the accepted value.- A run fails when the base grows more than 10% above that value.
- TM10 records a target to shrink it.
- Every report shows the totals and the base as well; neither is hidden.
- Terminal output written to disk still fails a run at any size.
- Each terminal's and each pane's CPU, memory and disk must be below
tmux's in the same run.
- Why: a first run of TM0's suite on the development host, not yet merged, measured tmux at about 4 MiB idle and a running Node at about 111 MiB PSS (28 October 2026). The Node already runs for Sessions, Routines and the fleet, so what a terminal adds is the cost a person pays for the multiplexer. A total against tmux can't pass while the Node runs on Node.js. D-182 keeps the Node in TypeScript/Effect; reducing its base is measured work within that runtime.
- Rejected: totals against tmux, which can't be met with today's Node; totals against the current terminal workspace manager.
- Consequences: the
terminalsuite's failure rules change. Results for other tools stay outside the repository. - Links: D-175, benchmark, roadmap.
D-178 Agents manage Routines as configured; by default they have full control (2026-10-28) — status: accepted
- Decision (owner, 28 October 2026): Planned (TM9). "both based on
configuration, by default agents have full control". Routine changes are
human-only until TM9 ships this.
- A Project's
.maia/config.jsoncsets how far agents manage its Routines:full,proposeorread.- The setting is read only from the Project's admitted
.maiarevision. - Without a setting it is
full, by the owner's choice. This is an explicit exception to failing closed. - A Project with no admitted revision resolves to
read, as does a setting that cannot be read or is invalid. - Existing Projects without a setting become
fullwhen TM9 ships.
- The setting is read only from the Project's admitted
full: an agent creates, pauses, resumes, runs and disables the Project's Routines through the operations a person uses. Create, run and disable take a preview, then a confirmation, compare-and-set and a receipt. Pause and resume take compare-and-set and a receipt, as they do for a person.- The agent confirms its own preview, through the same CLI or tool a person uses. A coordinator (D-086) such as Claude Code or Codex confirms like a Session.
- Its confirmation is bound to the preview's digest, actor, revision,
expiry and target. It is valid only while the Project's setting is
still
fullwhen it confirms. - Roles on each Node later decide which people and agents may confirm (D-180).
disablestays terminal (D-142). An agent's disable preview says the Routine cannot be resumed, and pause is the reversible choice.- Edit joins the list once the registry has an edit operation. Today a Routine changes by disable and create.
propose: an agent's preview waits until a person confirms it in a trusted client.read: agents only read and list.- Bounds that hold at every setting:
- An agent is a coordinator (D-086) or a Session credential. A coordinator reaches the Projects its operator's grant covers. A Session reaches only the Projects it is granted.
- A Routine an agent writes may name only:
- a Connection the Project's admitted
.maiaallows; - an executor the agent's own grant permits.
- a Connection the Project's admitted
- Both are checked at preview and again at confirm. The prompt runs with the fire's Project-only authority (D-142).
- A Session's own confirmation is refused in these cases, and its
preview then waits for a person, as under
propose:- its turn has loaded untrusted content, as the standing approvals rule defines it;
- its Session was started by a Routine's fire.
- Every change is in the D-168 trace, with the agent as actor.
- Only a person changes the setting, through
.maiaadmission in a trusted client. An agent may propose a change but never admits one. This bound stays under roles (D-180).
- A Project's
- Why: the owner wants Maia fully manageable by people and agents, and Routines are how work gets automated. The setting lets a team require a person.
- Rejected:
- Human-only Routine changes (the old rule).
- Agent control with no setting to restrict it.
- A
fulldefault that also covers invalid settings. - A person-only rule for coordinators. People and agents use the same CLI, so roles decide (D-180).
- Coordinator-created Routines that start paused.
- Consequences:
- Routine confirm operations gain a second confirmation class: an agent's
confirmation, valid only under
full.trusted-client-requiredstays forpropose,readand invalid settings.- The coordinator registry's rule that confirming previews stays with a person changes for Routine operations only.
- A worker's approvals and questions stay with a person (D-086).
- Coordinator and Session tools gain the Routine operations above (TM9).
routine runarrives in TM9 as a preview and a confirm. Each new operation needs its own protocol entry, SDK method, handler, receipt and contract tests. - The CLI reference's "every change is human-only" holds until TM9 lands.
- Alpha 1's model tools exclude only actions that stay human-only, such as a person's terminals.
- Workspace-owned Routines still follow R3.
- Routine confirm operations gain a second confirmation class: an agent's
confirmation, valid only under
- Links: D-086, D-142, D-175, D-180, Routines.
D-179 Maia Node moves to Rust on a parallel track, in a Cargo workspace beside pnpm (2026-10-28) — status: superseded by D-182
- Decision (owner, 28 October 2026): "maia node/servers should run on
rust instead of node", starting now, alongside Alpha 1.
- Two tracks. Alpha 1 ships on the TypeScript Node. A Rust Node grows
beside it to parity, then replaces
services/node, which is deleted once every scenario runs on the Rust Node. - One contract.
packages/protocolstays the only source of operations, schemas, errors, risk and exposure. A generator writescrates/protocolfrom it, and registry parity fails the gate on any drift. TypeScript clients reach either Node through the same operations, transports and errors, so the switch is invisible to them. - Parity is measured.
- Contract tests target the operations and run against both Nodes.
- Discovery files and the
MAIA_HOMElayout get their own parity check. - A subsystem moves only when the Rust Node passes its tests and is no worse in the footprint suite.
- An operation added during the track lands in TypeScript first. The Rust Node refuses it until it reaches parity, and RN5 needs every one.
- Numbers before and after. RN0 measures the TypeScript Node's
baseline on the development host with the
nodesuite, plus what it lacks today: idle memory (PSS), idle CPU, idle disk writes, throughput under concurrent clients, mutation latency and the server install's size. Every RN step runs the suite against both Nodes in the same run and reports each change with the benchmark's comparison statistics. The switch (RN5) publishes the whole table. It needs no metric past its warn threshold and a lower memory base. - One authority at a time.
- A profile runs exactly one Node. Both Nodes take the same exclusive
lock in
MAIA_HOME, and a test proves a second Node is refused. - Until RN5, the TypeScript Node creates and migrates every store.
- The Rust Node never migrates. It opens a store only at a schema version it implements and refuses any other.
- So switching a profile, or switching it back, is a stop and a start, with no migration in between.
- RN5 moves the migrations to Rust, each one proven on fixture stores.
- A profile runs exactly one Node. Both Nodes take the same exclusive
lock in
- Rust where it pays. Every new native part is Rust: the Node, the
process host (ported from C), the screen engine and the terminal client.
The Pi worker stays a Node.js process the Node launches, because Pi
ships as official npm packages. TypeScript stays for the registry,
@maiaos/sdk, the CLI, client-core and the Desktop and mobile renderers until a crate replaces one.- If TM2b lands first, RN1 ports its link to the screen engine.
- If RN1 lands first, TM2b links the engine into the Rust process host.
- Layout. A root
Cargo.tomlworkspace keeps every new crate undercrates/, beside the pnpm workspace, with a pinned toolchain (rust-toolchain.toml). Desktop's Tauri crate stays inapps/desktop/src-tauri. The gate runs cargo fmt, clippy and test for the crates that changed. Each crate's dependencies need the owner's approval (D-175). - Release. RN5 also needs the Rust Node's release contract:
- the platform build matrix;
- signing;
- the
@maiaos/nodepackage that ships it (D-134).
- Organization. The owner asked for a monorepo that people and agents navigate, change and verify easily, reorganized where that helps. An audit sets the target layout. Moves land in small steps once the in-flight branches merge, and source organization records them.
- Two tracks. Alpha 1 ships on the TypeScript Node. A Rust Node grows
beside it to parity, then replaces
- Why: in a first, unmerged run, a running Node used about 111 MiB, against tmux's 4 MiB (D-177). A Rust Node shrinks that base and the server install, and the owner prefers Rust "where ever we can". A parallel track keeps Alpha 1 on time.
- Rejected:
- rewriting the Node before Alpha 1, because Alpha 1 would wait months;
- starting only after Alpha 1;
- Rust beside each component's TypeScript;
- moving every folder before the audit;
- running two Nodes on one profile.
- Consequences:
- The Effect rules still bind all TypeScript. RN0 freezes the Rust conventions in source organization and AGENTS.md.
- The development host and CI gain a pinned Rust toolchain.
- The Rust track has its own implementers, and Alpha 1's order is unchanged.
- Links: revised native track, benchmark, D-175, D-177.
D-180 People and agents are principals with roles on their Node; a role decides who confirms (2026-10-28) — status: accepted
- Decision (owner, 28 October 2026): "ai agents and human, can use maia
cli, so technically both can confirm, maia is full cli controllable, later
we will have access control/account/role based via node so can fully
control … human and agents will be role controlled via their related
node". This amends S1's person-only confirmation and the roadmap's ORG1,
ORG3 and ORG4.
- Today. People and agents drive the same CLI and API. Whoever holds a principal's credential acts as that principal. A rule that only a person confirms therefore binds only the callers the Node can tell apart: the coordinator principal (D-086) and Session credentials.
- Planned (ORG1, ORG3). Every person and agent is a principal with an
account and a role on the Node it belongs to.
- An agent principal (a coordinator, Session, Routine, service account or token) has an accountable person. Its role is never above that person's, as ORG1 already rules for people. An extension's service account never confirms, whatever its role.
- A role decides what its holder may read, preview and confirm, per scope and risk class (ORG4).
- Person-only becomes a policy an organization sets per role, not a fixed rule. ORG4's two-person rule stays: where a policy names one, the requester never confirms its own request.
- Where a slice says only a person confirms, S48's goals for example, that stays the default until ORG4 lets an organization set it.
- Bounds under every role:
- A confirmation binds the operation, input digest, actor, revision, expiry and target.
- Receipts and the D-168 trace name the actor.
- Widening one's own access needs another person's confirmation, as in ORG1. An agent never grants or widens a role.
- A worker's approvals and questions stay with a person (D-086).
- Extension services never confirm (extensions).
- Why: Maia is fully controllable from the CLI by people and agents alike. A person-only rule the Node cannot enforce gives false assurance. Roles on each Node give the control.
- Rejected:
- Person-only confirmation as a fixed rule.
- An agent's role above its accountable person's.
- Consequences:
- Under D-178's
full, coordinators confirm their own Routine previews too. - The roadmap's ORG1, ORG3 and ORG4 treat agents as principals with roles.
- Under D-178's
- Links: D-086, D-178, organizations.
D-181 Alpha 1 lanes raise the CLI standalone ceiling by their measured growth, to at most 385 KiB (2026-10-28) — status: accepted
- Decision (owner, 28 October 2026): each Alpha 1 change that passes
STANDALONE_CEILING_BYTESinapps/cli/src/bin/maia.test.tsraises it by its own measured growth. The dashboard lane is one of these lanes.- The raise is the growth, rounded up to the next KiB.
- The "from" and "to" bytes go in the comment beside the constant and in
the squash message. The "from" figure is
mainat the change's merge base. - The package's packed growth is approved the same way, in the existing review comments.
- All raises together stay at or under 385 KiB (394,240 bytes). Going past that needs a new decision and a CLI cold-start measurement.
- A change raises nothing before it measures. A change that fits leaves the ceiling alone.
- Why: each Alpha 1 lane fits 353 KiB (361,472 bytes) alone, but together they reach about 361–366 KiB. The standalone payload is compressed, so trimming source moves its size by hundreds of bytes either way (D-160). Measured raises keep each lane's cost attributed.
- Rejected:
- Holding 353 KiB and cutting each lane to fit. That slows Alpha 1.
- One raise to 385 KiB with no accounting per change.
- Links: CLI rules, D-154, D-160, D-161.
D-182 Maia Node stays TypeScript/Effect; Rust is limited to the terminal and native process/PTY host (2026-10-29) — status: accepted
- Decision (owner, 29 October 2026): "we stay with typescript/effect as
before, only using rust for terminal + native process/PTY host". Supersedes
D-179 and amends D-175 and D-177.
- Node.
services/noderemains the single TypeScript/Effect runtime authority: validation, authorization, durable state, Sessions, workers, orchestration, receipts and database migrations. The registry, SDK, CLI, agent and client business logic keep their TypeScript/Effect boundaries. - Rust. TM2b's terminal screen engine, TM4's terminal client and RN1's native process/PTY host are the approved scope. Desktop keeps its existing Tauri shell. Native hosts own bounded OS resources under the Node's interface; neither a host nor a client gains business authority.
- Retained groundwork. RN0 keeps the pinned toolchain, root Cargo
workspace under
crates/, Rust gates and CI, the terminal client's registry-generated protocol binding and parity checks, and the Node's performance baseline. Desktop's Tauri crate stays inapps/desktop/src-tauri. RN0 freezes Rust conventions in source organization; dependency and crate approvals still apply. - Host replacement. RN1 ports the C process/PTY helper behind the same bounded protocol and lifecycle tests, including failure and cleanup paths. RN1 and TM2b may land in either order: preserve an existing engine link, or link the engine into the Rust host. Compare the complete Node/helper footprint before and after; the TypeScript Node is present on both sides.
- Containment. S47 still replaces worker containment with OpenShell. Its retirement scope covers replaced worker-containment machinery, not the terminal/PTY or screen-host responsibilities. Agent terminals remain inside their Session's containment; a native host is no alternate policy or execution authority.
- Removed track. RN2–RN5, a second Rust Node, store parity, profile cutover and moving database migrations to Rust leave the active roadmap. The terminal-first delivery order remains; D-183 adds an early dogfood checkpoint before full Alpha 1 qualification.
- Node.
- Why: the owner wants a usable system quickly and Rust at the native terminal/process boundary. Keeping the current runtime avoids duplicating operations and state during a full server rewrite. Performance work starts from measured bottlenecks and compares complete workloads; a fixed idle runtime cost alone does not justify replacing the Node.
- Rejected: a parallel Rust Node rewrite and migration handoff; extending Rust into SDK, CLI or application business logic; treating a process host as another backend or policy owner.
- Consequences: the native track is RN0–RN1, alongside TM2b and TM4. Node startup, idle footprint, throughput and mutation measurements remain useful without a second server. This decision changes the plan; it does not activate a new runtime or qualify unmerged work.
- Links: roadmap, layers, source organization, benchmarks.
D-183 Deliver a usable Linux-hosted dogfood checkpoint before full Alpha 1 qualification (2026-10-29) — status: accepted
- Decision (owner, 29 October 2026): prioritize the first usable dogfood
release, then close the gaps in the maintainers' actual daily workflows until
Maia replaces the current terminal workspace manager. Amends
D-175's milestone order by adding an earlier checkpoint.
- Qualify an installable Linux-hosted path on an explicit client platform: Claude Code and Codex across registered Nodes, Project/Workspace views and panes, send/wait/read, approvals and failures, detach/reconnect, and manual/scheduled Project Routines with exact-ID routing.
- Prove a real task on Maia's repository as a person and as an agent. Exercise client disconnect and Node restart, report what survives and what needs an explicit resume, and publish exact commands and gaps.
- Inventory the maintainers' daily workflows and migrate them gradually with recovery steps. Broader platforms and the complete production feature set follow where those workflows do not require them; each scheduled job has exactly one active scheduler during cutover.
- Full Alpha 1, all-platform qualification and the production migration retain their acceptance requirements. Authentication, grants, containment, confirmations, receipts and credential custody remain prerequisites for each supported workflow. D-182's language boundary applies.
- Why: the owner wants a system they can use as soon as possible, then a complete replacement for their current daily tool. A smaller qualified path provides early use and evidence without waiting for every platform or production feature.
- Rejected: waiting for full all-platform Alpha 1 before any daily use; calling a partial workflow full replacement; lowering safety or recovery requirements to reach the checkpoint.
- Consequences: the manager session ships small reviewed slices toward the checkpoint first, with explicit limitations. Complete replacement is proven against the workflow inventory, not inferred from this checkpoint.
- Links: delivery sequence, terminal multiplexer, first version, D-150.
D-184 A coding Claude Code tree on Linux runs Claude's own sandbox, and its settings carry Maia's denials (2026-10-29) — status: accepted
-
Decision (owner-approved mode; lead, 29 October 2026): amends D-019 and D-110 for coding Claude Code Session trees. A coding Session's launch request asks for the Linux host's
vendor-sandboxfilesystem mode (filesystem: "vendor-sandbox") instead of carrying Landlock read denials; the process manager refuses the field for any other owner or beside read denials, and the helper honors it only for the approved Claude binary (the launch carries the Claude Code authorization). A reading Session's request carries the denials and never the field, so a reading tree keeps Maia's Landlock filesystem rules even when its denials are missing. The cgroup, limits, seccomp filter,no_new_privs, signal and abstract socket scoping and the containment check are unchanged. The Node moves the denials Landlock gives up into Claude's own settings, written per Session on Claude's command line (inline--settings,--setting-sources=with no source,--disallowedTools), never in the user's~/.claudeor a file the worker can edit:- Claude's Bash sandbox
denyReadgains the runtime directory and the Maia auth file, each as named and where it lies (its real path, through the nearest existing folder for an auth file not created yet); - Claude's rules deny
Readof both paths and everything beneath them, andEditof anything under/sys; - Maia's PreToolUse guard refuses outright, with no person to ask, a
Read, Grep or Glob that reaches either path (named, through a link, or
a search root above one) and an edit under
/sys. Before this, an outside path went to a person, who could approve what Landlock had refused. Only a whole..counts as a step up, in a path or a glob, so<path>/..canarystays inside the path; a glob leaves the worktree when any of its brace alternatives does, and a backslash-escaped character counts as itself; - before the process manager starts anything, the Node reads Claude's
command line by the Agent SDK's grammar: each argument must be one of
the flags the SDK writes for a Maia worker, in the form the SDK writes
it, with its value. An unknown flag,
--, a positional argument, or a flag that Claude would take as another flag's value refuses the launch (configuration_invalid). Read that way, the command line of every Claude Code worker must carry--setting-sourcesonce and empty, so Claude loads no user, project or local settings. Claude still applies its inline--settingsand the host's administrator-managed policy settings: D-184 assumes the host's administrator policy is trusted, and the Node does not check it. A coding worker's must also carry--settingsonce, whose JSON equals the sandbox Maia planned with no field added, removed or changed at any depth, and--disallowedToolsonce with exactly Maia's denial rules. A reading worker's carries neither. Only then does the launch ask for the mode. Because the settings must match exactly, an Agent SDK that adds a field fails closed until Maia plans it.
- Claude's Bash sandbox
-
Why: a coding Claude Code Session's Bash could not run on Linux: Claude confines it with bubblewrap, which must make the root mount a slave, and Landlock refuses any mount change inside a domain that handles filesystem access (
bwrap: Failed to make / slave), as it did Codex's. The same trust trade as D-110 lets it run: Claude's sandbox enforces reads and writes, and Maia keeps cgroup, seccomp and signal scoping. Observed on the lab Node (Ubuntu 26.04, Linux 7.0, Claude Code 2.1.302, 29 October 2026), with real attempts that had to fail:- Bash read none of the runtime directory's bearer, a canary beside it,
the auth file or the Node's state database, listed no entry of the
runtime directory, and reached none of them by
/proc/<pid>/root, hard link, copy,/dev/fdreopen, or a link to the file or its folder; - Bash wrote neither its own tree's
pids.maxnorcgroup.procs; its writes outside the worktree landed in the sandbox's own temporary mounts and left no host file; its network requests outside Claude's rules got the proxy's 403 and were logged as sandbox violations; - named directly, both paths were refused to Read by Claude's rules and
to Grep and Glob by Maia's guard, and a Write under
/sysby Claude's rules, leaving no file; through links the worktree planted, Read and Grep were refused by the guard, and Grep and Glob with no path matched nothing (ripgrep follows no link); in four reads of a link swapped in a loop, Claude's own check refused one, and no canary byte reached the model; - a
.claude/settings.jsonandsettings.local.jsonwritten into the worktree mid-Session changed none of this; - a coding Claude Code Session then edited a worktree of Maia's
repository, adding a test to
scripts/lib/commit-message.test.mjsthat passed (node --test, 2 of 2).
- Bash read none of the runtime directory's bearer, a canary beside it,
the auth file or the Node's state database, listed no entry of the
runtime directory, and reached none of them by
-
Rejected: (B) turning off Claude's Bash sandbox for coding Sessions and relying on Landlock alone (Bash could then write anywhere the user can and reach the network); (C) refusing coding Claude Code Sessions on Linux, which stays only where the host lacks a prerequisite (prerequisites).
-
Consequences: inside a coding Claude Code tree, Maia no longer enforces the
/syswrite denial or the read denials with Landlock. Claude's sandbox does for every command Bash runs, and Claude's rules and Maia's guard do for its file tools, which run in the Claude process. Codex trees already run without Landlock filesystem rules under D-110; this extends the same trade to coding Claude Code trees. What the trade accepts:- Trust reaches beyond Bash. The Claude process, its file tools, the worker MCP server and any program Claude starts outside bubblewrap run with no Landlock filesystem rule. Each can read both paths and write the delegated cgroup parent, including other trees' limits. They are trusted as the approved binary.
- Bash reads are a denylist. Claude's sandbox refuses the listed paths
and lets Bash read the rest of the host, so folders such as
/tmp,/optand/srvcan be readable. - The Claude process's isolation is not established from other
processes'
/procentries, the Node's loopback API, pathname Unix sockets, D-Bus or the ssh-agent socket. - cgroup integrity depends on the vendor's cooperation, as D-110 says.
- Path checks are not an atomic boundary. The guard and Claude's rules
match paths, where Landlock matched the files. A link swapped between
the guard's check and the tool's use can escape; Claude's own recheck
narrows this, but Landlock no longer backs it. A hard link or another
mount of the same file under another path escapes them (Bash cannot see
the files to link them, observed). The real locations are read once, at
launch. A path holding a character Claude's rules read as a pattern
makes those rules wrong, while the guard and
denyReadstill hold.
The host prerequisites (bubblewrap, socat, nested user namespaces) still refuse a coding Session up front where they are missing.
-
Links: Linux process host, worker credential, D-019, D-110.
D-185 Every green push to main publishes a canary; nightlies stop and latest stays frozen until v0.1.0 (2026-10-31) — status: accepted, amended by D-202
- Decision (owner): amends D-132.
- Canary per push.
.github/workflows/publish.ymlpublishes one0.1.0-canary.Nfor the newest green CI run of a push tomain(a push superseded while another publish runs is skipped; the next canary carries its changes). A canary can still be published on demand from a green branch, and a dry run builds and tests without publishing. - No nightlies. The nightly channel, its
NIGHTLY_ENABLEDswitch and the job that movedlatestare removed from the workflow. latestis frozen until the v0.1.0 public launch. Stable, next and nightly versions stay refused by the workflow.
- Canary per push.
- Why: a nightly was a canary with a date and a skip rule; dogfooding needs each merged change installable, and no stable release exists yet. One channel is less to run and to explain.
- Rejected: keeping nightly behind a switch (an unused path to keep
tested); pointing
latestat the newest canary (every default install would follow unreleased builds). - Consequences: GitHub keeps one running and one pending run per group,
so a burst of pushes can skip intermediate canaries; the newest green one
publishes. A dispatch shares the group so canary numbers never race; one
that fails its green check can replace a pending push publish. The release
scripts (
version,release-plan,publish-npm) no longer know the nightly channel or thelatestpromotion: they refuse a nightly version with a "canary builds only" error, andpublish-npm.mjshas nopromotecommand. v0.1.0 adds stable publishing as its own change. Installers still read an existing nightly build. - Links: release.
D-188 One event core: a versioned envelope, one journal per Node, cursors and federation (2026-10-31) — status: accepted
- Context: Session activity,
watch --json, MCP and remote feeds, outbound hooks, Node resource events, questions and the dashboard all need events. Built one by one they would each add an emitter, a store, a cursor scheme and a retry loop, and a consumer could not see all Nodes through one subscription. The Node already has an append-only SQLite journal (domain_events) with global and per-aggregate cursors, written in the state change's transaction. - Decision: Amends D-003 and D-038 in part (below).
The event core is the only way to
produce or consume an event.
- One envelope,
maia.event.v2(it supersedes the unemittedEventV1target): ULIDid(monotonic), per-Nodeseq,node,source,kind(<source>.<noun>[.<qualifier>…], existing kinds keep their names),ts,v,subject, optionalcommand, a payload registered per kind (4 KiB by default for new public kinds). The registry lives inpackages/protocol; the journal, append, retention, read and federation live in one Node module (services/node/src/events/). Workers never own a journal. - The journal is the existing SQLite
domain_eventstable, evolved by one migration (event ID, source, version, millisecond timestamp, nullable command) and a journal epoch. Durability is a configurable fsync policy,always(default) /interval/never, mapped to SQLite'ssynchronous; torn writes are detected by WAL frame checksums. Retention defaults to 14 days or 64 MiB, whichever comes first, set per Node in configuration, pruned in batches; a slow consumer gets a gap, never unbounded disk. - Consumers resume from a signed cursor: at-least-once, deduped on
id; webhooks keep their own durable cursors. A gap returnscursor_reset_required. - The control Node reads each remote Node's journal over the existing SSH
bridge with one cursor per Node. Per-Node order is exact; global order is
(ts, node, seq), approximate under clock skew (the merge key is monotone per Node, and an event arriving after a later-ordered one is flaggedlate). No durable mirror: remotes replay from their own journal, and a remote down past its retention reports a gap. - Events carry metadata and digests, never bodies, transcripts or terminal text (D-168); a question's wording is resolved at delivery time. No event comes from screen scraping (D-175).
- A command's result reports the final revision after all events it wrote.
- Amends D-003:
synchronous = FULLis no longer mandatory; it is the default ofevents.fsync(always), withintervalandneverallowed. - Amends D-038: events keep 14 days or 64 MiB (not 90 days); receipts keep 90 days. Pruning ships only after the state that reads event rows moves to durable columns (EV0d). The operations references are updated.
- Delivery is sliced EV0a–EV0e, EV1–EV6; see the spec. D-186 and D-187 are planned.
- One envelope,
- Why: The state change, its receipt and its event commit atomically only in the store that holds the state. Reusing that store adds no fsync, no process and no second recovery path, and the Node's footprint stays inside D-177.
- Rejected:
- One emitter per feature: duplicated stores, cursors and retries; no single place to bound memory or to subscribe across Nodes.
- A separate events package: one consumer of the journal code (the Node); AGENTS.md extracts a package only for two. The protocol package already owns the contract. A generic package may be extracted once a second project adopts the same envelope.
- A second journal beside
domain_events: two sources of truth, and an event could disagree with the state it describes or lose atomicity with it. - Segment files: cheaper bulk delete, but a second durable store that cannot commit with the state change, with its own fsync knob, recovery and index. Revisit only if a B0 measurement names the journal.
- An event daemon or broker: an extra process to supervise and bound; the Node already owns durable state and the SSH bridge.
- A durable mirror of remote journals on the control Node: a second copy to bound and reconcile; replay from the remote Node's own journal is enough while its retention exceeds a typical outage.
- Consequences: Existing
domain_eventstypes are registered and keep their names; E1's activity event moves onto the core's append (see the spec). Hooks,watch, MCP and the dashboard add no store. A Node whose outage outlasts its remote journal's retention loses events and reports a gap. B0 gains append, read and idle-memory metrics. Planned: EV0a–EV6 in the delivery slices. - Review checklist (the PRs of EV1–EV5 must satisfy it):
- Shared core only: no new emitter, side channel, daemon or second journal.
- Envelope complete and versioned; payload validated strictly against its
kind's schema; any payload change adds a
v, and consumers skip an unknownv. - Journal crash-safe, fsync policy configurable, retention bounded by size and age.
- Consumers and webhooks resume from persisted cursors; handlers idempotent
on
id. - Multi-node aggregation through per-Node cursors, with per-Node
seqorder and the monotone-key global merge order with itslateflag. - Webhook secrets from environment or secret files only; retry with backoff and jitter, dedupe, visible delivery status.
- Resource events with hysteresis and reachability events.
- Overhead measured in
pnpm metricsand spec updated.
- Links: event core spec, D-168, D-175, D-177.
D-189 Foundations by ratchet, not rewrite (2026-10-31) — status: accepted
- Context: The F0 audit found clean client layering but a tangled Node: 31 of 41 top-level areas form one strongly connected component, a few primitives repeat (prefixed IDs, immediate transactions), SQL rows are asserted instead of decoded in places, and no rule detects import cycles.
- Decision:
- Each primitive has one owner module with a small documented API. Telemetry, errors, transport and extension admission already have owners; their rules and tests get stronger, and no new logging, result, RPC or retry framework appears.
- Dependency direction is enforced by a graph ratchet in CI that records exact edges and membership by kind (type, static-value, lazy), never totals. The baseline only shrinks.
- Work lands as small PRs F1–F10 (sequence), each green on its own, each with the gate that keeps it fixed.
- A duplicate goes away by moving its consumers to the owner and then deleting it, not by a compatibility layer.
- The event core keeps its own order (D-188).
- Why: A rewrite risks supported behavior; a ratchet makes each fix permanent and cheap to review.
- Rejected:
- One large refactor: unreviewable, and it blocks main.
- Totals-based budgets: an equal-sized swap hides a new cycle.
- A global ID prefix registry, a generic retry policy, blanket native-process replacement: they erase real distinctions (native hosts need descriptor control).
- Links: ARCHITECTURE.md, roadmap, D-188.
D-186 An orchestrator Session answers its direct children's questions, never approvals (2026-10-31) — status: accepted
- Context: D-086 lets only a person answer a worker's approval or question. A Session that supervises children then stalls on every question until a person returns. D-180 already decides who confirms by role on a Node.
- Decision: amends the question half of D-086.
- An orchestrator Session credential may run
approval.list,approval.showandapproval.respondfor approvals of its direct children only. - Only a
questionapproval takes an answer from it. It never approves, denies or approves for the Session a command, file change or tool call. - Any other Session (sibling, grandchild, parent, unknown, another tree)
gets the same
role_denied, so a probe learns nothing. - The receipt names the acting Session (
actor_<sessionId>); it is the actor record until the dedicated D-168 trace, still Planned, lands. Digest binding, expiry and replay by command ID are unchanged. session approval answerskips the terminal rule only under a Session credential.approveanddenystill require a person.- The external coordinator stays person-only until ORG1.
- An orchestrator Session credential may run
- Why: The answer is the supervisor's job and stays attributable; the commands that change the machine stay with a person.
- Rejected:
- A new
supervisorrole type now: a second role system before ORG1. - Letting orchestrators approve: it hands them command authority.
- A new
- Links: D-086, D-168, D-180, roadmap MP4.
D-190 Maia ships its own agent Skills and installs them into each detected harness through the skills CLI (2026-10-31) — status: accepted
- Context: An agent in any harness should be able to drive the Maia CLI
with no manual setup. Maia bundles two Skills today (
maia-orchestrationin themaiaplugin, andmaia-worker, injected at launch), andmaia connection detectreports the installed Claude Code and Codex CLIs. Nothing installs Maia's Skills into the harnesses a person already uses, and nothing lists every harness with its Skills folders. - Decision (owner, 31 October 2026): Planned (AI0–AI3). Extends
D-170 with Maia's bundled Skills; D-170 and D-040
otherwise stand.
- Bundled Skills (AI0). The Maia package carries versioned Skills for
its own procedures: Sessions (create, attach, wait, read, answer
questions) and Nodes, then events and hooks as EV2 and EV3 ship their
commands, each with
--nodeand--json. A CI check compares every command and flag a Skill names with the CLI's command tree and fails on drift. It extends themaia-orchestrationbundle check, which checks command names today, to flags. - Detection (AI1).
maia agents detect --jsonlists the harnesses installed on a machine, their versions and their user-scope Skills folders (Project scope follows in AI1c), locally or on the Node--nodenames. Harnesses and versions come from the detection behindmaia connection detect; Skills folders come from theskillsCLI's agent list. Maia keeps no per-harness path table. - Install (AI2).
maia skills install|update|uninstall|status(with--harness,--projector--user,--dry-runand--json) runs the pinnedskillsCLI with copies only. Its lock, with versions and folder hashes, is the manifest; Maia writes no second one. Before a write, Maia compares each installed folder with the lock: a folder a person edited (modified) is backed up and reported, never silently replaced. Install is idempotent, and uninstall removes only what the lock records. Each command is a Node operation in the protocol registry; install, update and uninstall follow preview, confirmation and receipt, and--dry-runshows the preview.maia setupand the first run offer the install, and a Maia upgrade updates the bundled Skills it installed. - Proof (AI3). Install, update and uninstall tests per harness in temporary home folders, and a README section.
- Bundled Skills (AI0). The Maia package carries versioned Skills for
its own procedures: Sessions (create, attach, wait, read, answer
questions) and Nodes, then events and hooks as EV2 and EV3 ship their
commands, each with
- Clarification (AI1). The pinned
skillsCLI (1.7.0) has no command that lists agents or their folders;skills list --jsonlists installed Skills with their absolute folder and the agents it attributes them to. Detection therefore reports a Skills folder only once it holds an installed Skill (folders: []means none reported, not unsupported), attributes it by the CLI's agent display names, and keeps no path table. AI2 creates a missing folder through the CLI. Detection does not run the pin throughnpx: plainnpxfrom a Project runs askillsbinary and obeys a.npmrcthat the Project carries (an offline regression test proves it). It runs the CLI the Node bundles (D-193) and installs nothing. AI1 ships user scope only; the pinned CLI follows symlinks and readsSKILL.mdinside a Project before Maia can filter, so Project scope (AI1c) needs its own containment design. - Why: the owner: any harness should drive Maia with no manual setup.
Composing the
skillsCLI keeps one pin and one per-harness path table, the reasons D-170 gives. - Rejected:
- Consequences: AI2 delivers S13's Skills commands for Maia's bundled
Skills first; S13 keeps instructions, MCP servers, hooks, permissions and
Project Skills. D-040's release tests (
skills list --json, the lock formats and the folder hash against the pinned CLI) cover AI2. - Links: D-170, D-040, Skills, roadmap AI0–AI3.
D-191 A pruned history returns an oldest-retained marker, never a silent hole (2026-11-01) — status: accepted
- Decision (supervisor, 31 October 2026): a list of one aggregate's events
(
session events list, andterminal events listfor an unavailable terminal) adds an optionaloldestRetainedCursorto its result when a prune removed events after the caller's cursor, soafter + 1 < oldest. The value is the aggregate's lowest retained sequence, or its durable revision plus one when every event is pruned (rule I5 applied per aggregate). A page without a hole is byte-identical to today's.receipt showaddseventsPruned: truewhen0 < eventCursor <the journal's oldest retained sequence; a live-terminal receipt stores0and is never marked. A live terminal keeps its own retention and reports an evicted range as an in-bandgapevent. - Compatibility: wire decoding rejects unknown fields, so a client built before the markers fails on a page that has a hole. This is an alpha paired-upgrade break, first reachable when the pruner is enabled.
- Why: the journal pruner (EV0e) removes the oldest rows. A caller that resumes from an old cursor would otherwise see a gap as if nothing happened.
- Rejected: an error for a pruned cursor (it breaks a reader that only wants what is left), and a marker on every page (it changes every result while the pruner is off).
- Links: events reference, D-188.
D-192 The CLI standalone ceiling rises from 371 KiB to 372 KiB (2026-10-31) — status: accepted
- Decision (owner, 31 October 2026):
STANDALONE_CEILING_BYTESinapps/cli/src/bin/maia.test.tsrises by 1 KiB, from 379,904 bytes (371 KiB) to 380,928 bytes (372 KiB), under the per-change accounting of D-181.- Measured on CI-style builds:
mainat b40ea1ec is 379,736 bytes and the AI0 change (#154) is 379,920 bytes, 184 more. The growth comes from one sharedresolveCommandthat the CLI and the guidance drift test both use. - The raise also covers the planned
node statusfields of EV0e. - The AI2 install commands bring their own measured raise and entry.
- The packed growth is approved in the existing review comments: +100 bytes, 507,780 measured at d9de9f38.
- Measured on CI-style builds:
- Why: the change fits no longer under 371 KiB, and trimming source moves the compressed payload by hundreds of bytes either way (D-160).
- Rejected: trimming the shared function to fit. It would keep two copies of the command resolution rules.
- Links: D-181, D-160, D-190. Raised again by D-194.
D-193 The Node runs a bundled, exact-pinned skills CLI (2026-11-01) — status: accepted
- Context: the Node runs the
skillsCLI foragents.detect(AI1) and for installs (AI2). Plainnpxornpm execfrom a Project runs askillsbinary the Project plants, and a runtimenpm installtrusts the host's npm, its configuration and its registry. - Decision (owner, 1 November 2026):
skillsis an exact-pinned runtime dependency of the Node package (1.7.0, no range). The Node resolvesskills/package.jsonfrom its own module, readsbin.skills, checks that the entry file stays inside the package folder and that the version is the pin, then runs it with its own runtime (process.execPath) and fixed arguments. Nothing is downloaded at run time. This amends D-170's "throughnpx" for Node-run reads and installs.- The pin moves by a reviewed change to a release at least 7 days old, with every package it brings into the lockfile (release).
- The dependencies are
tar7.5.22 andyaml2.9.0 (both new to the lockfile) and four small packagestarneeds. - The Node runs the CLI as a
skills-listingprocess owner under the resource manager, on Linux hard containment (tree control; it does not isolate the filesystem). macOS and Windows report the scope asunavailablewith reasonunsupported. The vouch policy admits oneskillstarget (the Node's own interpreter, by digest) for that owner kind at the hard-containment tier only. The pinned 1.7.0 CLI writes at most 64 KiB to a pipe (measured: 400 Skills make an 82 KB listing and the pipe carries 65,536 bytes), so a listing of about 320 Skills or more arrives cut off, reads asinvalid_outputand leaves the scopeunavailable, neverok. A longer home path lowers that figure, and a pin bump re-measures it. The Node's own 256 KiB stream bound is a ceiling for a different CLI, not a limit a pinned run reaches; a paced flood past it reads asoutput_too_large, and a burst the host cannot pass on fails closed asfailed. More than 9 or more distinct folders for one harness (the protocol caps 8) also read asoutput_too_large, never as a shortened list; a folder whose shown path passes the protocol's 512 characters is dropped. - A burst that outruns the Node's reader (more than 256 KiB or 256 chunks
pending) makes that launch's host fail its protocol. The manager then
keeps the tree fenced as
cleanup_unknownand holds one admission slot until the Node restarts. The helper runs per launch, so no other tree is affected. The pinned CLI's stdout stops at 64 KiB and cannot make a burst; the stderr path is capped in the listing's reader at 16 KiB but can still reach the host queue first, so the latch covers it. After the first fenced tree the listing starts the CLI no more for the Node run (unavailable,failed), which bounds the cost at one slot. Releasing a fenced slot once the OS tree is verified gone is a manager-wide follow-up, not part of this slice. - One deadline of 4 s covers the whole read (package check, launch recheck, CLI and folder pass), under the SDK's 5 s. Measured on a loaded Linux build host at load 8 to 14: the first read takes 2.3 to 3.6 s for 1, 25, 100, 175 and 250 Skills alike, so the package check (1.7 to 1.9 s, of which about 0.6 s hashes the interpreter) dominates and the Skills count does not. The real-host tests run on the shipped 4 s deadline. A first read at load about 10 to 11 took 2.9 to 4.2 s and missed the deadline in two of four runs, and every later read still took 1.4 to 2.0 s, because each launch re-checked the closure and re-hashed the interpreter. The Node therefore starts the package check in a scoped background fiber when it builds the service (it is built with the Node's other services, and readiness never waits for it; shutdown interrupts it). The hashing of the code files costs about 33 ms; the 127 MiB interpreter costs about 0.55 s. A launch still re-walks and re-hashes every code file. It skips only the interpreter re-read, and only for the running Node binary on Linux: Linux refuses writes to a running executable, so its identity (device, inode, size, mode, owner, ctime and mtime in nanoseconds, with the open file checked against the path's) cannot hold while the content changes. Every other interpreter path is read in full each time. The 0.55 s hash was the largest part of each later read's 1.4 to 2.0 s, so a later read now costs the rest (not re-measured in isolation after the change; the metrics compare shows no idle-memory change). A launch can still lose to a change that lands after its check and before the spawn, as before. The interpreter skip trusts ctime only on a local file system, so it applies when the running Node binary sits on ext2/3/4, xfs, btrfs or tmpfs (checked with statfs at first use); any other type, or an error reading it, such as FUSE or NFS, reads in full. A real-host test asserts the kernel refuses a write open, a held writer and a held writable mapping of a running executable. Skipping the file re-hash too was rejected: a same-user writer can change a mapped file without moving ctime or mtime. The listing stays far below the roughly 320-Skill cut. The warm-up and reads share one permit, so one check runs at a time. A read the warm-up already holds the permit for leaves the check running when its deadline cuts the read short; the warm-up gives up after 30 s and leaves the memo cold, so a hung file-system call cannot hold the permit for good, and the next read checks again; a read that holds the permit runs the check itself and is interrupted with it. Only a passing answer is kept, so fixing the files needs no restart. The closure digest is taken at the first passing check. A read that waits behind another counts that wait toward its deadline.
- The CLI starts in the real home, not the Node state folder, under the same Landlock read denials the vendor executors get (the profile runtime folder and the Maia auth file). The real-host tests prove a denied file is unreadable to the tree.
- The listing asks for 3 s of CPU, 256 MiB of memory and 8 processes. The manager's own hard-containment suites prove those limits; the Skills tests prove the deadline, the flood bound and the cleanup report, and do not read the limits back.
- The packed install is verified at publish, not in PR CI: the publish
workflow's installed lifecycle starts the packed Node and calls
agents detectagainst it. A PR run lists Skills with the bundled CLI through the real process host. - Before every start the manager rechecks the execution identity: the
interpreter, the manifest, the entry, every code file of the package and
its dependency closure (owned by this user or root, not group or world
writable, resolved inside the Node's own install). Changed files, a link
inside the package or a substitute outside the install refuse the start.
Installs made under a group-writable umask are refused, so install under
umask 022; the scope is thenunavailable, never a startup failure. - The environment carries a fixed key list plus
HOME; the credential keys,NODE_OPTIONSandNODE_PATHnever pass. The canonical home contains the response (a folder outside it is dropped); the read denials, not the home, limit what the CLI's tree reads. Output is capped, a flood reads asoutput_too_largeor fails closed, and a process that does not end cleanly isfailed. - The listing starts under hard containment like every worker. On Linux below
6.12 (Landlock ABI below 6) the helper refuses every such launch with
landlock_unavailable, so the scope readsunavailablewithfailed; no probe runs ahead and nothing starts uncontained. The supported kernel is in release and operations. It readsfailed, notunsupported: the platform is supported, and the kernel contract makes an old kernel a host failure the person fixes.
- Rejected:
npx: it runs a Project's planted binary and obeys its.npmrc.npm exec --prefix: it still trusts the host's npm and registry.- A runtime install: it downloads at run time, so detection could change the machine and depends on the network.
- Links: D-170, D-190, Skills.
D-194 The CLI standalone ceiling rises from 372 KiB to 374 KiB (2026-11-01) — status: accepted
- Decision (supervisor, 1 November 2026):
STANDALONE_CEILING_BYTESinapps/cli/src/bin/maia.test.tsrises by 2 KiB, from 380,928 bytes (372 KiB) to 382,976 bytes (374 KiB), under the per-change accounting of D-181. One raise covers two known growths:- #170 (the saturated cause kept in SDK discovery) measures 381,108 bytes, 476 over main's 380,632 at 7a525427.
- The honest-status change (
landlock_scoped_unavailableandupgrade_kernelin the status protocol and CLI text) lands under the same ceiling and records its own measured delta in its pack approval comment.
- Why: only about 300 bytes were free after AI1b (D-193), and trimming source moves the compressed payload by hundreds of bytes either way (D-160).
- Rejected: a raise per change. Two known, small growths fit one decision.
- Links: D-181, D-192, D-160. Raised again by D-200.
D-195 Session activity is announced from the write path with a checkpoint (2026-11-02) — status: accepted
- Decision (plan v3, EV1b): each Session row keeps the last announced
activity (
sessions.activity,activity_approval_id, migration v077). Every write that decides activity marks the Session; one before-commit hook per enclosing transaction compares the checkpoint with the live value and appendssession.activity.changedthrough the journal owner. Existing Sessions take their live value in the migration with no event. A start-up reconcile settles stale checkpoints without a command ID. - Why: deriving activity at read time cannot tell a consumer that it changed. Settling after the last write and before the commit keeps the event in the same transaction as its cause, and a coverage test lists the writers.
- Measured (the hook alone): on a file database, 2,000 bare Session state
flips that each change the activity take 1.03 ms at p95 with the settle hook
(derivation, checkpoint and one appended event) against 0.09 ms for the same
flip without it, under the 20 ms append ceiling
(
MAIA_NODE_RUN_EVENT_BENCHMARK=1,settle.test.ts). This is not a full command latency or allocated-page growth measurement (receipt, primary event and revision included); that is not claimed here. Each such mutation adds one event of about 30 bytes of stored payload to the journal (event_journal_meta.payload_bytes), against 0 bytes before EV1b. - Rollback: pending marks and the noted command live outside SQLite, so
every transaction owner that rolls back discards them first
(
discardBeforeCommit), and the owners that write activity (the journal's write transactions,inImmediateTransactionand the Session transition transaction) clear the command context when they begin, so a stale ID never reaches their next transaction. A transaction opened elsewhere, such as the connection upkeep store, does not take part and does not write activity. A failure that code catches inside a transaction which then commits is not covered: its marks stay and settle with the commit. - Exposure: the kind is
public(owner priority, 2 November 2026): it is the first kindevents.readreturns. Every other kind stays internal. - Downgrade: older Nodes refuse a database at v077, so going back means restoring the backup taken before the upgrade.
- Migration clock: the v077 backfill judges approval expiry by the migration's supplied timestamp, never the wall clock.
- Rejected:
- An event per write: one command can move several states, so consumers would see transient values.
- Settling after the commit: a crash would lose the event.
- Links: Events, D-188.
D-196 Updates never stop a running Session: helpers outlive the Node and one updater rolls back on its own (2026-11-02) — status: accepted
- Decision (owner requirement, 1 November 2026; design by the lead):
updating, restarting or rolling back a Node never stops a running Session,
terminal or harness. The ZU track
implements it, and ZU0–ZU2 gate RT3.
- Linux first, as for the dogfood checkpoint (D-183); macOS follows in ZU3.
- Process-host helpers outlive the Node and talk to it over a private local socket. The new Node re-adopts them from the journal and Session state. Terminals and Codex Sessions are adopted live.
- Claude Code SDK Sessions drain, then resume. An update waits until each is between turns, recycles the idle process and resumes it by session ID, and never cuts a turn; after 30 minutes by default the machine is skipped and retried later. Live adoption of SDK Sessions waits for ZU3, and only if needed.
- One updater: canaries arrive through the remote install and rollback path (a source candidate), pulled by each Node and including the bridge launcher. It checks npm registry signatures and provenance, stages the new version side by side and runs preflight migrations on a copy of the state. Then it swaps, health-checks and rolls back on its own, and keeps three versions plus any version a live Session still uses.
- Releases N and N-1 work together, with skew counted in release IDs. Migrations are expand/contract with a minimum-reader schema version, and the helper protocol is versioned on its own.
- Rollout runs in rings by exact Node ID, one canary machine first, with a soak gate, a pause and kill switch, and no update on a machine short on headroom.
update.started,update.doneandupdate.rolled_backgo through the event core.- An upgrade/downgrade CI suite with live dummy Sessions fails if any process dies, any output byte is lost or any approval is orphaned.
- Why: canaries land on every green push (D-185). Work running on many machines must not depend on when an update happens.
- Amends: D-090 and D-098. A process the new Node adopts is proven alive, so it is neither fenced nor freed as crashed. The ZU0 contract lists which start-up recovery rules an adopted process keeps or re-earns: credentials, abandoned approvals, and no retry while an adopted owner lives.
- Rejected:
- A second, timer-driven updater with a
currentlink. Linux units pin exact release paths, and the remote install path (a source candidate) already stages, swaps and rolls back with receipts. - Pausing Sessions during an update. That stops work, which this decision exists to prevent.
- Live adoption of Claude SDK Sessions in the first version. The tool-use guard runs inside the Node, so draining between turns is simpler and cuts no turn.
- An own signing key for canaries. Key custody stays an open decision for RL5 and RL8.
- A second, timer-driven updater with a
- Consequences:
- The first ZU release cannot keep or roll back Sessions started before it.
- ZU2a replaces RL4's automatic update and RL6's pause-based update; there is no second update path.
- Dates are best cases until the ZU0 spikes land.
- Amended 2026-11-02 (ZU0 spikes):
- Helpers and their holder run in their own systemd unit
(
KillMode=control-group), apart from the Node unit. A Node restart never touches the helper unit; an explicit drain stops it. Reason: in one unit withKillMode=process, a restart cleared the delegatedsubtree_controland the helper failed closed withcontainment_lost. - The helper control socket is abstract, owned outside every target's Landlock domain, and opened with a lease-token handshake. Terminals have no Landlock domain, so for them the token is the only barrier.
- A worker's environment is fixed at launch and a new credential revokes the old one, so an adopted Session keeps its credential, bound to the helper ID and start time. It is revoked at once when adoption fails or the lease ends; every other credential is still revoked at start.
- Cutover scope: "survive a restart" for the first cutover means a drained restart and a resume by ID. Sessions keep their IDs, resume their conversations and no turn is cut, because the update waits for idle. Live adoption (ZU1a) follows after the cutover.
- Evidence: spikes; contract: zero-impact updates.
- Helpers and their holder run in their own systemd unit
(
- Links: D-090, D-098, D-183, D-185, D-188, release, operations.
D-198 An opt-in host-authority Session runs the vendor harness as the Node's user, without the Maia sandbox (2026-11-02) — status: pending owner (Proposed)
- Decision: a person may create one executor Session with host authority.
Claude Code or Codex then runs in an existing directory (typically a git
checkout) as the Node's own user: with that user's network,
git push,gh, installs and credentials, and with the user's own vendor configuration, settings endpoint, hooks and MCP servers. Sandboxed stays the default and the only mode of every other path.- Opt-in:
--host-authorityonsession create, protocol fieldhostAuthority: true, with the vendor's own--permissionmode (permission) passed through unchanged. Without--permission, Claude Code uses its own default: its permission requests route through Maia approvals, and Claude's own permission mode and rules decide which calls ask. Codex starts with the fixed approval policyon-request, which overrides the user's Codex configuration.bypassPermissionsis allowed only as that explicit host opt-in, matching the risk of today's lanes: it is never a default, only the operator's create sets it, the Session stores it withcontainment: noneand shows it in create, show, its receipt and itssession.createdevent, resume and recovery relaunch with the stored value, and the kill switch refuses its launches like any host launch. A coordinator's approval grant never reaches a host-authority Session: only a person answers its requests. It takes no worktree, Agent or role. - Operator only, to create and to drive: only the operator principal
creates a host-authority Session or prompts, starts, resumes or answers
it. A Session credential (so an orchestrator), the coordinator
(
maia mcp), a Routine and Run dispatch are refused withrole_denied, on the persisted mode, never on the caller's claim. A child never inherits the mode, and a parent's mode is never consent. - Persisted, immutable mode: the mode and permission are stored with the Session at creation and a trigger refuses any later change. Anything malformed fails closed. A resume reuses the stored mode and never upgrades a sandboxed Session; adoption of a native session carries the flag by the same rules. The mode and permission are part of the create request digest.
- Kill switch: the Node starts with
MAIA_HOST_AUTHORITY=enabledor the mode is off. It is checked at the shared activation admission, before any launch preparation, so create, start, resume, adoption, retries and startup recovery (queued and ready) all refuse withhost_authority_disabled. A refusal fails that one Session; the others still recover.node statusreportshostAuthority: enabled|disabled. - Visibility: the Session reports
containment: noneinshow,list(text and JSON) andsession.created, from the same persisted field. The command receipt names the actor, command and Session. The event names the Project, Workspace, executor,cwd, permission mode,authorizedBy(the actor who opted in) andcontainment: none. Neither carries a credential value, an environment dump or raw vendor configuration. - Environment: an allow-list, built before the Session credential, the
worker MCP server and any gateway credential, which a host Session never
gets, and enforced at the final spawn after the SDK's own additions
(
HOME,USER,LOGNAME,SHELL,LANG,LC_*,TZ,TERM,PATH,TMPDIR,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_CACHE_HOME,SSH_AUTH_SOCK,GH_TOKEN,GITHUB_TOKEN, when present). NoMAIA_*variable, Maia bearer or Session credential is passed. - What stays on Linux: the Node's delegated cgroup with its limits,
cgroup.kill, the seccomp filter,no_new_privs, and Landlock's signal and abstract-socket scoping. What is dropped: Landlock filesystem rules and read denials (the helper's existingvendor-sandboxmode), and with them Maia's vendor-sandbox profile, guard and MCP/feature overrides.sudoand setuid programs do not work inside the tree.
- Opt-in:
- Accepted limits (owner-accepted bar: the same risk as running the vendor
CLI by hand today, and no more):
- A host-authority Session holds the Node account's authority, including read access to Maia's own credentials and state (the 0600 bearer). The environment allow-list stops injection, not access. The owner accepts this by enabling the switch.
- Its cgroup accounting is cooperative: an unrestricted child can move descendants out of the cgroup, so limits bind only the processes that stay in it. Supervisor protections are kept where compatible.
- The
cwdcheck limits the launch location only. A swap of the directory between the check and the launch is not defended against; a later slice may close it.
- Why: fleet lanes need Claude Code and Codex to work in an existing
checkout with network,
git pushandgh, as the session runner being replaced does. The sandbox (D-015, D-184) rightly forbids that for workers. - Amends: D-015 and the containment policy: containment is the vendor sandbox Maia configures, except for a Session its creator explicitly opted out of it.
- Rejected:
- A default-on or per-role host mode. A worker must stay sandboxed unless a person decides otherwise for one Session.
- Passing the Node's whole environment. It carries Maia's own credentials.
- A config file or runtime toggle for the switch. The Node has no config store, and a restart to flip it is cheap because updates drain (D-196).
- Letting the coordinator, an orchestrator or a Session request or drive the mode. That lets a sandboxed agent widen its own authority.
- Consequences:
- The Session's actions are the user's actions. Review comes from the transcript-free trace Maia already keeps, not from a sandbox.
- Only the owner accepts this entry. Until then it is a proposal and the code ships behind the kill switch, off by default.
- Both launches are source candidates. A host-authority Session never
runs sandboxed under a
containment: nonelabel: its launch takes the host branch before any credential, MCP or gateway preparation. - Claude Code launches with the user's
user,projectandlocalsettings sources, no Maia sandbox settings, hook guard, tool list, MCP config or plugin, and the caller's permission mode (the SDK'sdefaultwhen none). It uses a Claude Code Connection only: a system-default one leavesCLAUDE_CONFIG_DIRunset so the user's own settings endpoint applies; a gateway Connection is refused. The command line must match the planned flags, each once. - Codex launches the plain
codex app-server --stdio: no profile, feature, MCP, web-search, prompt or developer-instruction override, so the user's ownconfig.tomlapplies.thread/startandthread/resumeboth name the sandboxdanger-full-accessand the Session's approval policy (untrusted,on-requestornever;on-requestwhen none), and the launch fails closed unless Codex's response reports that policy anddangerFullAccess.on-failureis not a value: the 0.163 app-server rejects it, sosession createrefuses it. Only a system-default Connection runs a host Session; it leavesCODEX_HOMEunset (the user's own~/.codex), and a Maia-managed or gateway Connection is refused. - Maia has no command-approval flow for a host Codex Session: it declines
every command-approval request Codex emits, and no person can approve one
through Maia. Commands that need no approval still run with host
authority, and
nevermakes Codex ask for none. Underuntrustedandon-request, a command Codex would have asked about does not run, so those policies suit a lane whose work needs no command approval. - A host Session keeps the user's own MCP servers: Maia does not stop it when one starts.
- Links: D-015, D-184, D-196, containment policy, CLI reference.
D-199 A Project's guard can grant a coordinator the answer to its pending approvals (2026-11-02) — status: proposed (pending owner)
- Context: D-086 keeps every worker approval and question with
a person at a terminal. D-186 lets an orchestrator Session answer
its children's questions. A bot that supervises Sessions without being their
parent (the coordinator, over the CLI or
maia mcp) cannot answer anything and clears a lane's prompt by sending keys. D-180 says roles will decide who confirms, but the Node has no account or role records yet (ORG1, Planned). - Decision: amends D-086.
- The Project guard (S18b) gains one approval grant
for the coordinator, set by a person with the existing
project.guard.previewandproject.guard.confirm(digest-bound proof, compare-and-set revision, receipt). No new authority path. Its levels arenone(default, today's behaviour),answer(answerquestionapprovals) anddecide(also approve, approve for the Session and deny). - Orchestrators stay as D-186 left them: they answer their direct children's questions and never approve or deny. An approval can reach outside the Project (a file tool outside the worktree, another profile's credentials), which is why D-186 rejected it, and a Project grant cannot bound that. There is no orchestrator grant.
- A grant covers the pending approvals of Sessions of that Project, including requests already pending when it is enabled. Revoking it blocks future answers only; an answer already committed stands.
approval.listandapproval.showare unchanged: a coordinator already reads them. Onlyapproval.respondis gated. The coordinator sends the coordinator mark (maia session approval approve|deny|answer --as-coordinator, or theapproval_respondMCP tool); a person at a terminal is unchanged and the terminal requirement stays for everyone without the grant. A Session credential takes precedence over the mark.- Everything D-086 binds still applies: the request digest the actor read,
the 10-minute expiry, replay by command ID,
approval_closed,approval_expiredandapproval_request_changed. - Serialized check: the Node checks the current grant, that the approval belongs to a Session of the granting Project, that the grant permits the decision, and the kill switch inside the approval writer's critical section, just before the transaction that records the answer. That section is the Node's one writer lock, shared with the Project guard writes, so a revocation either commits before the check, and refuses the answer, or after the answer, which stands. The check runs before the replay and every other check of the request. The guard revision that authorized the answer is recorded on the resolution, which the receipt keeps. The request digest is unchanged.
- Refusal: every other case returns
role_denied: no grant, a grant too low for the decision, a Session of another Project, an unknown approval, or the kill switch. One code, so a probe learns nothing. - Receipt and event: the receipt and the resolved approval name the
acting principal,
coordinator:actor_local_user: the operator's actor marked as the coordinator (the bearer is shared, so there is no finer identity; see the caveat). Theapproval.resolvedevent is committed with that receipt, carries the sameactorIdand theguardRevisionin its payload and links to it by command ID. - A damaged approval record refuses a grant-based answer as
role_deniedand logs the cause; the operator path keeps its own error. - Kill switch: starting a Node with
MAIA_NODE_HEADLESS_APPROVALS=off(only an absent setting oronenables; a blank, malformed or unreadable value counts as off) refuses every grant-based answer for that run; it is read at start, so changing it needs a restart.node.statusreportsheadlessApprovals: on|off. Grants stay stored and work again when the switch is removed. - Guard receipts written before this decision have no grant and replay as
none. - A coordinator or Session never sets or widens a grant:
project.guard.*confirmation stays person-only (D-180).
- The Project guard (S18b) gains one approval grant
for the coordinator, set by a person with the existing
- Caveat (self-asserted coordinator): the coordinator mark is a header on
the operator's bearer. Whoever holds the bearer can send it, and could
already call
approval.respondthrough the SDK as the operator. The grant and the kill switch therefore constrain only correctly identified requests; a hostile bearer holder bypasses them (an existing D-180 limit), and they do not establish that a bot, not a person, is acting. The grant adds policy, scope per Project and an attributable record. Accounts and roles (ORG1, ORG4) replace the mark with an identity the Node can verify; the grant then moves to those roles. - Why: the supervisor bots need an API, not keystrokes. The Project guard is the one record a person already sets, with confirmation and a receipt, per Project; it avoids a second role system before ORG1.
- Rejected:
- A new
supervisorrole type or account table now: ORG1's job. - A global switch instead of per-Project grants: a Project of private work may keep approvals with a person while another does not.
- Treating the missing terminal as the grant: it is the very check this amends, and a pseudo-terminal passes it.
- An orchestrator
decidegrant: see above; D-186 stands. - Letting a Session or coordinator widen its own grant: D-180 forbids widening one's own access.
- A new
- Consequences: migration 76 adds one column to
project_session_guards. The default reproduces the behaviour before this decision. Worker approvals stay with a person unless a person grants otherwise. The first change stored and showed the grant; the second enforces it. - Links: D-086, D-168, D-180, D-186, vendor executors, CLI reference.
D-200 The CLI standalone ceiling rises from 374 KiB to 378 KiB (2026-11-03) — status: accepted
- Decision (lead, 3 November 2026):
STANDALONE_CEILING_BYTESinapps/cli/src/bin/maia.test.tsrises by 4 KiB, from 382,976 bytes (374 KiB) to 387,072 bytes (378 KiB), under the per-change accounting of D-181 and in the style of D-192 and D-194. One raise covers the queued growth, so the cutover week needs no second one:- Main at d7bdbee0 measures 382,792 bytes, 184 under the old ceiling.
- E1 (Session labels, #199) merged with main at 0a19f554 measures 383,548 bytes, 756 over main (measured).
- #192 adds about 208 bytes (measured on its branch).
- #195 adds about 1,400 bytes (measured on its branch).
- E5 adds a few hundred bytes (estimated).
- Together, about 385,600 bytes: over a 376 KiB (385,024) ceiling and 1,472 under 387,072.
- Why: only 184 bytes were free after the host-authority change (D-198), so the next growth of any size passes the ceiling, and trimming source moves the compressed payload by hundreds of bytes either way (D-160).
- Rejected: a raise per change; trimming unrelated source to make room.
- Links: D-181, D-192, D-194, D-198.
D-201 One manager per Project and owner, enforced by the Node (2026-11-03) — status: proposed
- Decision (lead, 3 November 2026): a Node refuses to create a top-level
orchestrator Session while another one of the same Project and
ownerlabel is not archived. The refusal ismanager_exists(HTTP 409, not retryable, CLI exit 70) and names the existing Session; the caller reattaches to it, resumes it or archives it first.- A manager is a Session with role
orchestrator, no parent, in a Workspace of the Project, with the labelowner=<name>. An orchestrator child is not a manager, and a top-level orchestrator without anownerlabel is refused (configuration_invalid). - Every route that makes a Session applies the rule inside the write
transaction that inserts it:
session create, host-authority create andsession adopt(which takes the optionalrole,labelsandtitleon its preview, bound into the digest). A replayed command answers from its receipt before the rule runs, so a retry never fails on its own slot. - A stopped or failed manager keeps the slot. The slot frees when its
archive finishes: migration 80 adds
sessions.archived_at, set in the transaction of the final successful archive, after the runtime ended and the worktree snapshot exists. A failed archive keeps the slot. Start and resume refuse an archived Session. - A replacement and its predecessor never run together. A launch has two parts: the work before its runtime registers it (the repository check) and the runtime's own registered fiber. The Node's launch gate owns the first part, so a stop or archive cancels it and waits for its end before it ends the runtime. Each runtime asks, once its fiber registers and right before the runtime opens, whether the Session still starts or recovers; a stop or archive that committed first ends the launch, and a later one interrupts the registered fiber. An archive never frees the slot under a launch that is still validating. A launch that began after a stop committed finds the Session no longer wanted. Resume, and its stale-runtime drain, cancel the older launches of the Session too. A cancel counts the launches in flight, then checks that the Session is still stopped (or resumable), and only then interrupts them: a retried stop never cancels the launch of a resume that committed since.
- Admission (the hold, grant and adoption checks that can fail an
activation) runs inside the launch gate, with the repository check, so a
stop or archive cancels and waits for it too, on the recovery path as on a
new launch. Each activation attempt is also fenced: the attempt carries
the Session's stop revision (
sessions.stop_revision, set whenever a stop or archive commits), and a start, failure or rejection write from an attempt whose revision is no longer current changes nothing. A stop and a resume that happen while a first admission is pending leave the resumed Session untouched when the first attempt fails late. The same revision gates the launch itself: the check right before a runtime opens also requires that no stop moved the revision since the attempt read it, so a stopped and resumed Session never runs its older launch beside the new one. The fence follows stops; a failed Session resumed without a stop relies on the resume drain that joins the older launch. - A manager without a name takes the title
<project name> · <owner>, cut to 80 characters by shortening the Project name. An explicit name wins. session list --label key=value(repeatable) returns the Sessions that carry every label, before pagination.session list --settled [--idle-for <duration>]returns the Sessions that are not archived and are stopped, failed, done (idle with a completed latest turn) or idle past the duration (default one day), before pagination, each withsettledReason. Idle time runs from the newest turn's last change, or from the Session's own record only when it has no turn, so a rename never postpones retirement.session archivetakes an optional caller-writtensummary(up to 2 KiB) andtranscriptPath. Both are bound into the command, kept in its receipt and returned with it. The Node records the path as given, never reads transcript content and guesses no vendor layout (D-168).- A manager create checks its slot before it provisions a checkout, so a refused create leaves nothing behind. A create that loses the race inside the transaction asks the worktree owner to remove the checkout it provisioned, and the branch ref stays, as after a prune. The Node keeps the checkout, and logs why, when any Session references the Workspace, the removal plan is not eligible or the removal does not succeed; nothing retries a kept checkout, and a person removes it. The reference check runs again right before the removal's transaction, in the same synchronous step under the shared writer, and counts Sessions in every state, so a Session that an adoption creates (stopped) between the plan and the removal keeps its checkout. Only this discard counts stopped and failed Sessions; the ordinary pruning of an archived worktree is unchanged.
- The adoption preview a person reads shows the role, labels and title the
confirmation commits. A manager preview without a title resolves
<project name> · <owner>at preview time and binds it into the digest. The preview also stores the request as sent (title omitted stays omitted), and a retry matches on that identity, not on the resolved title: after a Project rename an identical retry still replays the preview a person read, and an omitted title never matches a title a person chose. - Two identical creates that both miss the first receipt lookup meet the slot check once one has committed: the loser replays the winner's receipt before the check refuses it.
- Archive is final for every Session, with or without a worktree: start and resume refuse an archived Session. A Session archived without a worktree could be resumed before; that is a breaking change.
- Migration 80 backfills
archived_atonly where a worktree is already archived or removed (the worktree's last update time), so a legacy archived manager does not hold a slot forever. It invents no owners: existing orchestrators without an owner label hold no slot, and existing duplicates stay as they are.
- A manager is a Session with role
- Why: a person runs one manager per repository, and bots that restart must find that manager again instead of starting a second one. A rule kept in each client cannot hold when two clients race; the Node owns the slot, so it does.
- Limits (stated, not hidden):
- Activation ordering rests on the launch gate (cancel, join and state checks) and the stop-revision fence, not on one serialization per Session. A review of that design found no surviving race; the fence follows stops, so a failed Session resumed without a stop relies on the resume drain and join.
- The guarantee holds per Node profile database. For the cutover each Project has one designated Node and its bots are pinned to it; two Nodes with separate databases do not see each other's managers.
- The
ownerlabel is metadata the caller asserts. It names a slot; it does not authenticate a person. Who may act as an owner is still decided by the Node's actors and roles (D-180). - A host-authority Session with role
orchestratorrecords the role and holds the slot, but gets no Maia credential: the role bounds nothing for a Session that runs outside the Maia sandbox (D-198).
- Rejected: a unique index (
sessionshas no Project or role column, and the lineage role never changes); a per-client check; counting orchestrator children; inventing owners for existing Sessions. - Links: D-151, D-180, D-198, contracts, CLI reference.
D-202 Canary only, long-term (2026-11-03) — status: accepted
- Decision (owner, 3 November 2026): amends D-043,
D-132 and D-185, and supersedes the "until v0.1.0"
clause of D-185.
- One channel for good. Maia stays on the canary channel before and after the public launch. There is no stable, next or nightly channel, no promotion step and no stable release, not even at v0.1.0. The v0.1.0 name is not a release milestone.
latestfollows the newest canary. A separatelatestjob in.github/workflows/publish.ymlmoves thelatestdist-tag of every public package to each canary that publishes, after the release is recorded.latestfollows thecanarytag and moves forward only. A default install of a package therefore gets the newest canary, as thecanarytag does.- Only a real run of
mainpublishes, and only its tip. A canary publishes from a push tomain(or a dispatch frommain) that is not a dry run. A dispatch from any other branch is always a dry run: it builds and tests, publishes nothing and moves neither tag. This supersedes the "on demand from a green branch" clause of D-132 and D-185. The plan refuses a source commit that is not the tip ofmain, or that the newest canary tag already carries, before it allocates a version, so a rerun of an older run cannot publish older code over a newer canary or takelatestback. A refused plan skips the build and the publish and is not a failure. Thepublish,releaseandlatestjobs each repeat the whole rule (not a dry run, andmain) instead of trusting the jobs before them. - The hard block stays.
publishedChannelOf,requireCanary,installableChannelOf,installableVersionand thereleasejob still refuse any version that is not a-canary.Nprerelease, and the newlatestcommand refuses one too. Nightly builds published before D-185 stay installable and are never published again. - Automatic updates follow canary only. The updater (ZU)
reads the
canarydist-tag, neverlatestor any other tag, andmaia updatehas no channel to choose.
- Why: one channel is less to build, test, explain and keep safe. The
promotion path, its soak rules and a second install track served a stable
release that Maia no longer plans, and
latestwas frozen on a stale nightly that no longer matched what the updater and the installers follow. - Rejected:
- Keeping a stable channel for a later launch: unused paths that need tests and wording for a release that is not planned.
- Letting a branch dispatch publish: a branch canary would move the
canarytag, which the updater follows, andlatest, so any repository writer could put unreviewed code on every default install. - Moving
latestonly for canaries built frommain, with branch canaries still movingcanary: the two tags would name different builds for no stable release to protect. - Guarding the retry only by version number: a rerun of the whole
workflow for older source allocates a new, higher sequence number, so the
forward-only check on
latestpasses for older code. The guard compares commits instead. - Moving
latestinside the publish job: a failed dist-tag move (the one step proven only by a real run) would then block the verification and the GitHub release. - Deleting the stable, next and nightly names from the code: the release scripts need them to recognise and refuse such a version, and removing them touches the public export list and several files for no behavior change.
- Consequences: D-185's rejection of pointing
latestat the newest canary no longer holds, because no stable release exists to protect. The first run after this lands proves the OIDCdist-tag add: it needs npm 11.21.0 or newer (the job installs it) and dist-tag changes allowed on each package's trusted publisher. If it fails, thelatestjob fails and nothing else does; the packages not yet moved keep theirlatest, and a rerun of the job or the next canary moves them. Themain-only rule lives in the workflow and the plan, which a branch with write access can edit; the second layer is a protected,main-only environment required by each npm trusted publisher. It needs the npm settings, so it is an owner follow-up and is not part of this change. The publish step reads each package'scanarytag before it publishes a version npm lacks and refuses when the tag names a newer canary, so a rerun of the failed jobs of an older run cannot move the tag back. The main tip is read once, when the run plans: a push that lands later is the next run's canary, and the serialized runs publish in the order they plan. The telemetry documents still name per-channel defaults (D-046); this decision does not change them. Release, the roadmap and the READMEs say "canary only" and no longer name a stable promotion. TheRELEASE_CHANNELSvocabulary andversion.txtstay as they are. - Links: release, publishing canary, D-043, D-132, D-185.
D-205 Repository boundary: the managed cloud product lives only in the cloud repository (2026-11-03) — status: accepted
- Decision (owner, 3 November 2026): the managed cloud product, its landing
page, pricing, waitlist or access requests and signup flows live only in the
cloud repository. This repository holds the open-source product and its docs
site (
apps/site), which may mention that a managed version exists with one link and nothing more. Until a cloud address exists, the site carries no link. - Why: one owner per surface. A product page and its conversion flows change on a different schedule and for a different audience than open-source documentation, and the public repository must not carry them.
- Rejected: keeping a cloud landing or waitlist in
apps/site; describing cloud pricing or availability in the open-source docs. - Consequences:
apps/sitewording about a hosted signup, waitlist, invitation or managed control plane is removed. Managed-version planning lives outside this repository; the roadmap and docs say only that a managed version is planned. D-115 already places its control plane in a private repository. - Links: D-115, site README.
Imported from Maia OS docs at 3dc3e07