Skip to content

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 agent is 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 ~/.pi state, a Maia plugin or compatibility API, and public Fleet, Workflow, Task-graph, Bot or Subagent runtimes or CLI roots (Session-local task is 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 former swarm bridge command), 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.md loads 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.md from 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 SecretStore Effect 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 SecretStore or 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-Session auth.json with the leased access and refresh token that nothing removes. The owner must amend or supersede this entry; a proposed amendment is "local v1: owner-only auth.json plus 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.*/containerId become workspace.*/workspaceId; execution workspace.* becomes environment.*/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); execution workspace_* keeps its meaning. Migration 35 gives each Project one container named after it, and project.register creates 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-server and Claude Code through the Agent SDK driving the user's installed claude, each bound to a named account. The brain is Claude Code or Codex using the maia CLI 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 usageLimitExceeded with a reset time; the Agent SDK ran on the subscription plan without overage; spawnClaudeCodeProcess hands 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 claude binary 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 -c overrides. Claude runs with a fixed PreToolUse path hook (Read, Grep, Glob inside the worktree only).
  • Why: the spike showed Codex's built-in :read-only profile 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, unlike canUseTool.
  • Rejected: the built-in :read-only profile; canUseTool alone.
  • Consequences: /tmp stays 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-sdk with pathToClaudeCodeExecutable, a Node-owned spawnClaudeCodeProcess, settingSources: [], strictMcpConfig, never bypassPermissions or sessionStore.
  • Why: the claude process 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_pgid for Codex and Claude Code, vouched_runtime for Git and, for the secret-store owner 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 (clone3 with CLONE_INTO_CGROUP); the kernel enforces memory and a task cap, the helper enforces process count, CPU and wall time, cgroup.kill stops the tree and an empty removed cgroup is the cleanup proof. Before exec each target gets no_new_privs, a Landlock domain (no writes under /sys, no signals or abstract sockets outside the tree) and a seccomp filter (clone3 answers ENOSYS; prlimit64 and 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 (clone3 into another cgroup, prlimit on the helper, abstract sockets, chmod of 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 login is 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-pool or gateway command trees; connection add and 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 login and connection login/signin remain 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 connectionPool belongs to one Workspace or Project, in single or rotate mode. 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; rotate reuses 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-codex and 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) and lock (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 kill or 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_SLUG plus lock cover them); build-cache locks (tools already lock); a .localhost proxy (stable ports give stable URLs).
  • Consequences: S16. The port kind shipped as a source candidate on 2026-10-01: 15-minute renewable leases from MAIA_PORT_RANGE (default 41000-41999), an explicit --preferred hint, and MAIA_PORTS injected into Codex and Claude Code Sessions. The lock kind, 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 observe gains an attention value.
  • 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 all target.
  • 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 --schema and --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 contract family 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.v1 envelope (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 monitor becomes an alias of observe health --watch. Its migration table mapped context show to context inspect|lint|explain, provider list to connection list plus model list, made workspace an internal Project projection and run, turn, dispatch, execution and terminal leases internal records, and kept session, receipt, project, status, monitor and version; 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 monitor prints a loopback URL; turn and run task are 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 agent keeps Pi InteractiveMode. Durable attach uses a thin PiTuiRenderer over pinned @earendil-works/pi-tui, only in apps/cli, replaceable because that package is deprecated. No rooms, panels or dashboards; pi-terminal-ui is not ported.
  • Why: the terminal is a debugging client; it owns no durable state, policy or telemetry truth.
  • Consequences: maia session attach is 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 .pi discovery; 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 ~/.maia and <project>/.maia; Maia keeps Claude Code, Codex, Pi and Cursor equivalent at user and project level on every Node through config.resolve, plan, apply, drift and import. 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, .bak copies 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.apply runs the skills CLI (vercel-labs/skills, MIT) through npx at 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/skills after .maia in 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-ai Skill, installed through the skills CLI 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 (or MAIA_HOME): installed versions, current, profiles, runtime files and cache. Services run an exact version, never current. 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 atomic current link).
  • 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_HOME at 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.txt holds 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: only v0.1.0-canary.N tags publish, and only when the CANARY_ENABLED repository variable is true; stable v0.1.0 is 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 with protocol_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 ProtocolHelloV1 sketch 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 authoritative maia.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 basic level 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 and maia telemetry level to 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/telemetry scope.
  • 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/core and src/ui/theme/tamagui.config.ts are the sole theme, token, font and responsive authority.
  • Rejected: the tamagui umbrella 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 sideEffects fields and ships only dist/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:policy reads 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.ts and colocated tests; no generic folders (common, helpers, shared, types, utils, tests); maia-source/layout at 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-source ESLint 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:policy prints 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/protocol only. A protocol scope keeps its transport-neutral index.ts and may add one private effect-schema.ts entry. Protocol modules and tests import another scope's Effect Schema values through that entry; no index.ts, the package entry included, imports one. The exception lives in the source-organization policy object and the scope-entry-imports rule, 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/ and effect-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.ts is free of Effect, so scopes with no public surface give their Effect Schema to another scope (the shared wire primitives now sit in envelope/). The protocol build verifies every package export against its own declaration ceiling and every scope entry for effect, and a tooling test locks the public export names. Only the declarations stay Effect-free: importing the ./feature-registry subpath 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: tsc in every package is the native TypeScript 7.0.2 compiler, pinned in the catalog as @typescript/native (npm:typescript@7.0.2). The typescript name 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 is tsc6. The shared configs are the canaries @vllnt/typescript@2.0.0-canary.a1749e0 and @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-functional to ^10 and pins typescript-eslint 8.68.0.
  • Rejected: TypeScript 7 under the typescript name (it breaks every API consumer); the client's own typescript@6.0.3 beside @typescript/native (two tsc bins in one package); the stable 2.0.0 configs (owner direction).
  • Consequences: @typescript/native is the only package that links a tsc into a package's node_modules/.bin. pnpm lint:policy requires it through the catalog in every manifest whose scripts run tsc and allows typescript only through the catalog. The TypeScript 6 package behind the alias links its own tsc under node_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 metrics writes a versioned maia.metrics/v1 snapshot and pnpm metrics:compare compares 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. A metrics CI 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 a Metrics-Accepted commit 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 metrics branch written from CI, which needs contents: write; accepting regressions by editing thresholds in the same change.
  • Consequences: a failing metrics job 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), effect and @effect/*, and the operator's @vllnt/* packages. An exact ws@8.22.0 entry, which @effect/platform-node-shared 4.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/vitest 4.0.0 requires; its vite peer (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 Effect Context getter 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; Type comes from @earendil-works/pi-ai), @tailwindcss/typography, @mdx-js/react, postcss and autoprefixer (site), and lucide-react-native (client; six local react-native-svg glyphs, with Lucide's notice in third-party/lucide).
  • 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 ws below the range Effect declares; keeping the Babel workaround after its negative control stopped failing.
  • Consequences: a Pi upgrade re-checks the Type re-export and the built-in extensions; the release-age exclusions need a dated operator approval. Deferred: jsonc-parser and the unwired Node configuration code, the React Native peer-only packages (native build check), yaml and xstate. Update (31 October 2026): F7a removed the unwired Node configuration resolver, writer and parser and jsonc-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-effect ESLint rules (Promise orchestration, restricted imports, raw timers and fetch, ambient reads, module state, error classes and the console) run at error severity on application code in every workspace src/. The code that predates them is counted in the effectBoundaries section of the lint-policy baseline, which only shrinks (D-061).
    • New xstate imports 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-test rules reject module mocks of owned modules, as unknown as into owned types, wall-clock sleeps that sequence a test and vi.useFakeTimers in Effect tests, with a testQuality baseline that only shrinks.
    • Coverage is measured, not gated yet: Node's built-in coverage for the node:test suites, and @vitest/coverage-v8 for protocol and Node, pinned to the exact Vitest version.
  • 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-syntax and no-restricted-imports entries (a local rule override, which lint-policy forbids, and they would replace the shared profile's own no-restricted-syntax); allow-listing whole folders, which would hide unrelated gaps beside an adapter; a second baseline file; coverage floors before the numbers exist; c8 or another coverage dependency for node:test.
  • Consequences: pnpm lint:policy prints 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-ai and @earendil-works/pi-coding-agent move 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.0 but never imports, has one exact @earendil-works/chord@1.0.0 exclusion, 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, passes noExtensions, and an explicit builtin:<name> path fails to load. Node's Pi RPC sessions pass --no-extensions, which alone keeps them off; the Node-written settings.json also disables all four by name, which holds while --no-approve keeps 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.json and reads the profile) and an RPC session without either guard. Local maia agent and both managed worker modes, run in that hostile profile, read nothing under ~/.pi.
    • Two older ambient reads are closed: the managed worker's session.show projection 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.md and .pi/APPEND_SYSTEM.md into 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 only LANG and LC_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 gitHead a13d35a7) 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 agent opens Pi 1.0.0's fullscreen terminal UI by default; /settings switches it for the session. Sign in with ChatGPT, new on the openai provider, needs a stable device ID that Maia does not store yet, so maia login openai with 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, the make*Client factories, createMaiaClient and @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.ts entry, which other scopes and the scope's facade import and no index.ts does, so the root, ./node and voice declarations never reach effect (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, createLocalNodeEffectClient and their types. Its declarations reach effect. The Effect discovery and attached operations take the Crypto service from their caller for message identifiers; the Promise facade provides one built on node:crypto.
    • The CLI runs every command as an Effect program on one runtime at the process entry (runProcessMain). Its library entry's makeCli and runCli return an Effect of the exit code; usage errors are the typed CliArgumentError, and exit codes, JSON output and help text stay as they were.
  • 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/effect now (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/effect is 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. The fetch default of NodeApiTransportOptions is 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 of help and --version; Effect Schema, Stream and Node's Stdio load 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, /goal and the goal prompt frame, /tasks and the open-task digest, heuristic naming of unnamed sessions and the pi-prompt-conformance tool-metadata rules as a contract test, which reworded the goal and process tool 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 find and grep stay inactive: when they run, Pi looks for fd and rg under ~/.pi/agent/bin first, because it fixes that directory before Maia binds its own. Enabling them waits for R11 AG1.
  • Why: owner request. The source holds tested behavior worth keeping, but most of it assumes an ambient ~/.pi profile, ambient credentials or session-local durable state that Maia Node owns.
  • Rejected: importing the extensions unchanged (ambient ~/.pi reads, 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 tasks and goal commands 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 maia plugin (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 and maia mcp server. Without the plugin command, Codex takes a [mcp_servers.maia] entry and a linked Skill.
    • The maia-orchestration Skill 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 existing message, never new fields.
    • One end-to-end test drives the Skill's loop through the built maia mcp against 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 through runDesktopNodeWithExecutors, which the package entry does not export, so the maia-node executable never contains them; the test checks both.
  • 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-plugin package or agents/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://, or http:// 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 and disable.
    • The key is stored the way the Node treats auth.json (D-010): one gateway-key file, mode 0600, 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 gets ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN (bearer) and ANTHROPIC_MODEL with a per-Session CLAUDE_CONFIG_DIR; Codex gets a maia-gateway model_provider (wire_api = "responses", env_key = MAIA_GATEWAY_API_KEY) through -c overrides on an empty per-Session CODEX_HOME, and the key in MAIA_GATEWAY_API_KEY.
    • connection.models.list reads /v1/models once (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_gateways row beside a connections row 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 optional model.
  • 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.toml in the Codex home (a file write for values that -c already carries); ANTHROPIC_API_KEY for 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 remote probes 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 with ssh <host> <root>/profiles/<profile>/bridge, which runs maia-node bridge and 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's MaiaTransport. Every remote script travels base64 behind one fixed sh -c bootstrap.
  • 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 -L port forwarding, which adds a local listener and moves the bearer to the client; Tailscale-only reach (it stays optional reachability, and never Funnel); maia mcp run 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) behind run.ledger.*, beside the unchanged local-v1 terminal Run (run.create and 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_id is NOT NULL and references a terminal that must be an unavailable record; orchestration_attempts allows 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 is ON 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.inbox and run.question area 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; ledger blocked, ready, active, succeeded, stopped), until the owner converges or retires local-v1 (roadmap lists its consumers). The CLI grammar is maia 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, with MAIA_NODE_ORIGIN), sent as Authorization: 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_OPERATIONS table lists what a worker and an orchestrator reach; the Node checks it before any handler and scopes each operation to the Session (role_denied otherwise).
    • Guard limits live in the Node, per Project, set by a person with project.guard.preview and project.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.
  • 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 /tmp refuses worker launches, and files created later directly in $HOME are unreadable to workers. An environment is readable by the same user's processes (ps -E on 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 the maia-write profile (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.json in its canonical checkout lists the git-ignored files to copy and one setup command. session diff reads the change; session archive stops the Session, commits its work to the kept branch as Maia, and the Node removes the worktree an hour later through execution.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 .git file 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 in node_methods.rs, and registry parity fails on any difference. 60 operations are exposed; terminal.observer.attach, terminal.observer.resync and terminal.controller.renew are not, because the Node reports them unavailable.
    • One node_request command replaces the per-method native validators. Rust keeps the exact SDK envelope, the invocation ban, size, nesting, deadline and concurrency bounds and the bearer; the Node's strict decoding owns parameter validation.
    • The descriptor risk class decides native confirmation: consequential and destructive requests wait for the user's answer in a native macOS sheet that renders the exact request Rust will send. turn.send and interaction.respond are 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.send and terminal.input.write are exempt from the sheet and go through node_request like 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 no unsafe-inline or unsafe-eval) and agent output rendered as plain text or sanitized markdown with no raw HTML.
    • Review fix: terminal.create is 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 --desktop as a supervised child and stops it on exit; it never installs a service (RL3). MAIA_DESKTOP_PROFILE selects a named profile.
  • 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.send as 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.send are 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 resync instead of slowing the writer or other viewers. The durable lane commits finished items in one transaction per 400 ms in session_stream_records; deltas are never durable. Startup closes items and turns a crashed epoch left open.
    • session.attach is a second stream operation on the observation socket, authenticated by the local bearer like observe.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), synchronized with 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/cli is a public package and @maiaos/client-core is private, and the release check (scripts/release/npm-packages.mjs) rejects any unknown @maiaos/* dependency of a public package. The client-core session-attach entry 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-server 0.160.0 with a private CODEX_HOME against 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.
  • 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_records until 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-server thread/list with useStateDbOnly). Titles, first prompts and previews are content and need an explicit includeContentFields.
    • session.history.read is 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>.json or, for Codex, a person's attestation. Confirmation rechecks it and claims the native session in session_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.
  • 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_refused while another client runs it. Migration 49 adds session_adoptions and discovery_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.sqlite in a temporary CODEX_HOME, codex 0.160 thread/list left the database byte-identical with an empty WAL and created no rollout file, yet app-server startup appended to its own logs_2.sqlite, opened its goals, memories and queue databases and synchronized bundled system Skills. On the live ~/.codex those 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 keep thread/read with turns, which Codex deprecates only for paginated threads.
  • 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+K opens 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.
  • 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, cockpit and session-view scopes; 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 and turn.send are 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.install and node.remote.remove (preview, then a confirmation bound to the plan digest, the inputs, the actor and the registry revision, then a receipt) and node.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|remove drives the operations; a leading --node <name> routes any Node-backed command, maia mcp included, through one bridge per invocation. The install pins codex and claude (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> login runs the vendor sign-in there with ssh -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 --node must be ready, greet as its recorded profile, and never falls back to the local Node. On a host whose ~/.local/bin is 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_use and its tool_result, api_retry and the turn's result. It skips subagent frames (workers have no agent tool) and an API error's stand-in answer, which the result reports as an error item instead. A whole block, sent when partial messages are off, takes its frame's uuid as 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 reasoning item reading Thinking…, with the text's length as characters in its extension, on the live and durable lanes alike; the text, signature, signature_delta and redacted_thinking never enter a record (owner decision, 3 October 2026).
    • The prompt Maia sends is the turn's user message, and each result adds a usage item whose input counts cache reads and writes, as Codex's does; thinking tokens stay inside output, so reasoningOutputTokens is 0.
    • A call Maia's guard denies, or a permission_denied message names, completes declined. With permissionMode: "dontAsk" Claude asks nothing, so the adapter produces no approval_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.
  • 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 claude binary (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 streamed input_json_delta (the command or path is unknown until the input completes, and the complete assistant frame 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; usage covers the main agent loop and no subagent or auxiliary call; a synthetic fixture can drift from what a future claude emits, 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 attach streams Claude Code Sessions, showing that Claude thought and how much, never what. With thinking display omitted a 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 import subcommands other than scan, 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_previews so 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, but receipt list --method no longer filters by the retired methods. The single-valued kind column 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/sdk and the CLI before a client offers it.
    • planSessionCreation, createSessions and awaitSessionRunning turn task text into running Sessions with a first turn by composing workspace.list, session.create, session.start, session.show and turn.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 for receipt 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.list takes no projectId for the whole Node, states and attention filters, and returns the Node's attention value of each listed Session, by the rule of session.observe and its default stall window. The Node owns that value; clients never derive it.
    • classifyClientError returns one tagged MaiaError with a kind; a Desktop confirmation the person declined stays declined, never outcome_unknown.
    • watchNodeChanges runs over a platform StreamRelay and 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.send needs 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 out session.list per Project (one bounded whole-Node page serves it).
  • Consequences: the CLI adds session create with --message, --prompt-stdin or --from-file, session list --all with --state and --attention, and session start --wait, and prints attention in session list and session 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.detect reads, on the Node, each installed CLI's path, version and sign-in, and connection.system.preview/confirm (maia setup) turns each usable one into a system-default Connection plus an approved executable through connection.create and integration.apply.<vendor>. System default means the worker runs with the user's own vendor config: the login and the ~/.codex/config.toml it reads, such as a custom model_provider with 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 with settingSources: []). 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 only env.ANTHROPIC_BASE_URL and whether apiKeyHelper exists in settings.json, and model_provider, its base_url and whether auth.command or env_key exists in config.toml); running a credential helper during detection; setting CLAUDE_CONFIG_DIR=~/.claude (Claude keys its Keychain entry by it); a client-side batch of connection.create and integration.apply that the Desktop would have to allow one by one.
  • Consequences: the Node runs --version and claude auth status --json or codex login status itself, 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 in settings.json, because Claude Sessions load no user settings, and a Codex env_key endpoint, 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/group with no other member, and no other /etc/passwd entry 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/bin at 775, so the VPS reported codex and claude unavailable 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 mcp reaches the Node as the external-coordinator principal: it sends the operator's bearer with x-maia-principal: coordinator, and the Node admits the coordinator tool operations and refuses every other one with role_denied before any handler. Worker approvals and questions are one Node-owned resource, approval (approval.list, show, respond; table session_approvals). A coding worker's request (a Codex command, patch or question on approvalPolicy: "on-request"; a Claude Code read or edit outside its worktree, or AskUserQuestion) waits for a person; respond binds 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, deny and answer refuse 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 adds session_approvals. A Session holds at most 16 waiting requests; the Node refuses one more with approval_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 maia CLI. Before each launch of a coding Session the Node writes the reviewed maia-worker Skill 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 the Skill tool limited to it. Migration 55 records what each launch injected (worktree.skills in session show); .maia/worktree.json "workerSkills": false turns it off per Project. Reading Sessions, which have no shell, get none.
  • Why: Claude Code under the worker profile (settingSources: []) reads no CLAUDE_CONFIG_DIR/skills or .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, ~/.codex or ~/.agents; a real claude that loaded the plugin wrote no plugin or Skill into its CLAUDE_CONFIG_DIR.
  • Rejected: running npx skills@1.7.0 at each launch (network or npm cache on every launch, no digest pin through npx, lock files written into the worktree and the home folder, a Claude target workers do not read; D-040 keeps it for config.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 maia or 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 through maia mcp as an MCP server carrying their Session credential (S18c, the next slice), and the Skill then points at its tools. session create --message and --from-file also 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 failed or expired, never cleanup_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 retryable deadline_exceeded. At startup a previous process's started permit settles failed only 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 as cleanup_unknown. integration remove also starts from cleanup_unknown. A failed Session or turn carries a failure (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 is outcome-unknown otherwise. 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_unknown with no way out; every later Session failed with activation_failed and no reason. A dead endpoint or a bad key left turns running for minutes while Codex reconnected. The benchmark's 30-second turn wait stall 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, plan and apply; remove cannot 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 observe reports 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 (worker and orchestrator) may call session.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 is role_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 mcp starts (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 --worker as the reserved maia_worker stdio 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-scoped session.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_tools and required=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 maia on 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 count session start and session resume enforce. 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/4 from 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 start as saturated until a slot frees or the cap is raised; to use a free account, create a new Session. session create refuses 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 started and 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 its maia-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) returns ESRCH), which the host allows only once it saw no tracked member survive; every Linux tree cgroup under the parent is gone or reports populated 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 permit cleanup_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 setsid between 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 live maia-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 status then asks for both the vendor process and the Maia process host to be ended before remove, plan and apply. 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 maia and node in 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 selected maia, or a node in 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 or maia link Maia could not search; when a later entry supplies maia, 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/bin through 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 maia or node reached through a link in a sticky directory such as /tmp refuses 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_request takes an optional target, local or remote:<name>. Rust resolves the name through the local Node's node.remote.list (now exposed to Desktop as a read), runs /usr/bin/ssh with the SDK's options and bridge command, the same as maia --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 bridge on 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 ssh and configuration already do); Desktop running maia 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_connecting or node_unreachable with 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_input on, so its blocking question tool reaches the approvals queue outside Plan mode, and with a granular approval policy (a -c override) that refuses escalations, patches outside the worktree, rule prompts, permission requests and MCP elicitations; Maia holds item/tool/requestUserInput alone and declines any other request. The maia-write profile 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's HEAD, config, info, objects, packed-refs, refs and shallow), 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 macOS xcrun cache). GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM and xcrun_db reach the worker's commands through shell_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, an http.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 run git status (owner's ~/.gitconfig, the shared xcrun cache 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-writable xcrun cache, which names the binary a shim runs); a granular policy on thread/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 Codex allow rules 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-request while the maia-write profile 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 user allow rules 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, composite or local), a summary and an optional example. The tree replaces the help rows, the test inventory's usage strings and the families' usage literals. -h and --help work on every node and maia help <command>... names one; human root help is a section index. maia help --json describes the whole tree (summary, kind, availability, usage, example, operations) with a versioned contract block; a command's --help --json adds typed arguments read from its usage. A usage error (exit 64) adds the nearest command's usage and a hint. --node, --profile and -h/--help are global anywhere before --; a command that takes its own --profile keeps 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.cli usage 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=false and agents.enabled=false. A coding worker whose launch injected the exact reviewed maia-worker Skill also gets its body, without the front matter, as -c developer_instructions=...; the worktree copy and worktree.skills stay as D-087 describes, and Claude Code still loads the Skill as a launch plugin. The Skill no longer tells a worker to call context_show first; the tool stays available.

  • Why: Codex re-sends its whole prompt prefix on every model call, and a turn's inputTokens sums 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_agent removes the tools; bundled system Skills have no worker use; and the forced context_show added 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 global AGENTS.md, 7,471 to 3,807 characters (about 1,900 to 950 tokens) with an empty home. A real Codex coding turn (read, edit, run node, 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, and agents.enabled parses as a field where an unknown agents key fails.

  • Rejected: include_permissions_instructions=false (the approval wording is how a worker learns it may ask a person); replacing the base instructions with model_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/skills or .codex/skills are not offered to it; it can still read those files in its worktree. The worktree copy of maia-worker is no longer listed to Codex either; it stays as the record the instructions follow. Codex 0.160 ignores unknown skills keys, so a renamed key would silently bring the list back. A coding worker's developer_instructions replace 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_instructions at a thread's first turn. Two-turn real probes, where the first and the resumed launch each passed instructions A, B or none:

    First launchResumed launchThe thread follows
    ABA
    AnoneA
    noneBnone

    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 skills keys, connection detect now renders the installed Codex's prompt with codex 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 reports workerPrompt (ok, failed, inconclusive); maia doctor shows it as codex-cli.worker-prompt and fails it as skills.* keys no longer honored by Codex <version>. A misspelled agents key 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 SFSpeechRecognizer with requiresOnDeviceRecognition. Three native commands (dictation_start, dictation_finish, dictation_cancel) serve the trusted main window. The bridge admits one session at a time and the locales auto, en-US and fr-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.transcribe operation (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:desktop from 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 explicit readOnly and destructive hints. It replaces the unused agent field (every descriptor had agent: 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 agent beside a new mcp flag (two switches for one grant); deriving exposure from risk or LOCAL_NODE_METHOD_EXPOSURE, which classifies Maia agent requests, not the operator's own coordinator.
  • Consequences: MaiaFeatureExposurePolicy changes shape and the feature-registry entry exports MaiaFeatureCoordinatorTool. 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), and coordinatorToolDefinitions() and COORDINATOR_TOOL_OPERATIONS are 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 carry policy.ownReceipt, whether an operation records a receipt under its own method; the receipt.list filter 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, /settings and /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 automatic node_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.list cursors load.
  • 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-Node session.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 its NodeChanges service. The Session page mounts the live Session view (D-077) and puts its composer, with dictation (D-089), in the shell's dock; /nodes lists 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 under maia execution relocation …, the Node's own status under maia node status (with a top-level status shortcut), remote installs under maia 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-run and 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-stdin never prompts, so it always needs one of them), and --yes stays on setup, node install and node 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, --json included. MAIA_PROFILE, MAIA_NODE and MAIA_NO_INPUT stand in for --profile, --node and --no-input on the leaves that take them; a flag wins. -v is gone; --timeout and --stall-after take durations (30s, 10m); import scan pages with --after; lists default to the protocol's 20 items unless their usage shows another range; doctor replaces recovery; completion bash|zsh|fish is generated from the tree; version --json adds the protocol version and, in a build made with MAIA_CLI_BUILD_COMMIT, the commit. Each command-tree fragment lives in its feature's tree/ 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.cli becomes 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). A saturated answer is retried four times with short jittered backoff and the same request body. session turn wait exits 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); --yes on 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 show can print it.
  • Links: CLI reference, D-103; the saturated retry 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 fails resource_not_found naming 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 --project or --workspace comes from the current folder (sent only to the local Node), then the selected Workspace, else exits 64 naming the flags. JSON success gains resolved and human output one line per resolution on stderr. maia reference resolve shows 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.create takes one; otherwise the Node stores the first line of the Session's first turn. session.rename (reversible, command ID, revision guard, own receipt, session.renamed event, 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 adds sessions.title.
    • The SDK's typed call(method, parameters, options) serves every operation whose descriptor binds sdk.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 reports outcome_unknown). The CLI has one call dependency and maia mcp derives 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); --schema in this slice (the bundled schemas do not fit the size budget; it stays planned for R1); a worker-credential default (workers reach Maia through maia 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.resolve and session.rename (43 tools); the receipt filter by session.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 external git 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 saturated protocol error may carry saturation: admission when the Node's request, connection or Session request slots were full, or account when 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 only admission, and a client transport that was full; an account refusal, or one that names nothing, exits 75 at once. A completed turn records its token usage with an optional modelCallCount: Codex counts one call per raised thread token total in its thread/tokenUsage/updated notifications from the turn Maia started, Claude Code reports its result's num_turns (the SDK's model round-trips in the turn); the Session's usage item carries the same count. The setup preview repeats the requested maxConcurrentSessions and each existing Connection's cap. A running Session whose latest turn failed or ended with an unknown outcome reports turn_failed attention until a later turn is sent or the Session stops; a turn a stop ended raises nothing, so a resumed Session starts clean. turn_failed ranks after question and before stalled and settled_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 (saturated is 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 saturated refusals (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.list returns 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 of container.list plus one container.show per 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.list already returns discovered ones); project.show still 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 with storage_conflict; the Desktop cockpit restarts the index read up to twice, then reports the error. container.list and container.show stay 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 separate session.observe runtime 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-sandbox filesystem mode (MAIA_PROCESS_HOST_FILESYSTEM), which the process manager selects for a codex-session launch 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_privs and 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's maia-read and maia-write profiles as deny entries 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 @openai scope 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_landlock feature (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 /sys read-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 /sys write 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: its memory.max and pids.max (the containment check then fails the run within a second, containment_lost, which the vendor-limits host check pins), its other settings such as memory.swap.max, memory.oom.group and cpu.weight (unseen), its descendants' membership (a process moved to a sibling cgroup escapes cgroup.kill, and the linux_cgroup_empty cleanup proof then reports a clean tree while it runs, so that proof is best effort for a Codex tree), and other trees' limits, cgroup.kill and cgroup.procs or the parent's cgroup.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 a deny entry beneath the worktree for a file created, with its parent folder, after the app-server started unreadable and unwritable to its commands (observed with command/exec, no model call, under a profile shaped like maia-write without its Git grants). Whether a user's Codex allow rules 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 in vendor-sandbox; skipped where /usr/bin/bwrap is missing), the scoping a vendor-sandbox tree 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 the deny entry 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.list filter in local-node/receipt-list.ts is 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 its policy.ownReceipt is true, and the call table holds exactly the descriptors that bind sdk.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>/feature sub-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 mcp still 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_METHODS is 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/cli bundle (maia.mjs, staged beside maia-node.mjs) in the content-addressed release, so its digest is part of the release ID, SHA256SUMS and every re-verification, and writes versions/<release>/bin/maia (0700), a fixed /bin/sh launcher that runs that bundle on the release's runtime by absolute path. The unit's PATH lists that bin/ 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's bin/ over. Before the unit points at the release, bin/maia --json version runs from /, and the install fails unless it reports the release version. The plan names the launcher's path and the bundle's digest (cli), and maia node install prints them. Worker MCP is unchanged: it finds that maia through 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 PATH until 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 another node (on the dogfood host ~/.local/bin/node is v26), so the launcher never looks node up on PATH.
  • Rejected: the bundle itself as bin/maia: its env node shebang takes the first node on 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; a maia-node mcp --worker command, 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.mjs fails with artifacts_invalid and 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). A maia the 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 its bin/ 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, apply and 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/passwd and /etc/group. One rule serves the Node, the remote probe and the Linux process host: ownershipProblem and userPrivateGroupId in the launch scope, shell twins the probe runs, and maia_user_writes_only in linux.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 share staff and have no private group. A file the policy refuses for its shape, owner, mode, links or size fails with configuration_invalid and a message that names the path, its mode and the fix (for example chmod go-w '<path>'), not transport_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 setup and integration plan codex refused it with transport_failed, while session create pointed 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 script bin/codex.js is 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 gh CLI. 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 agent keeps 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 SecretStore Effect 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 main behind 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 main follows 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, every engines.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 five pi_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: engineStrict with an exact engines.node rejects any other Node at install, and the site image installs the whole workspace. The Pi RPC contract compares process.versions.node with 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_strict in 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 what pnpm check:quality ran. 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 carries Growth-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-verify or MAIA_SKIP_HOOKS=1 skips 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-verify or 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 main gives 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: and Metrics-Accepted: trailers in every paragraph of a commit message that holds nothing but Key: value lines 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 to main (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.yml publishes through npm trusted publishing (OIDC, no stored token):
    • nightly 0.1.0-nightly.YYYYMMDD.N after each green CI run of a push to main that changed a public package, while NIGHTLY_ENABLED is true. latest follows nightly until a stable release exists.
    • canary 0.1.0-canary.N on demand from any green branch; it never moves latest.
    • Stable and next stay blocked until the owner lifts this decision: publishedChannelOf refuses them in every release script.
    • The tested tarballs are the published bytes, and a nightly moves latest only 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.
  • 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:lab runs one to four detached Nodes from a dev worktree, each with its own profile, HOME and 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_KEY for Codex, ANTHROPIC_AUTH_TOKEN for Claude Code). HOME isolates Node state until RL3 gives each profile its own home. up records the TMPDIR it starts the Nodes under, and every lab command, dev:worktree remove and dev:clean use it. One lock orders up, down and those removals, and is never taken over: a stale lock is removed by hand. dev:worktree remove and dev:clean refuse while a lab Node runs. down stops 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's HOME or MAIA_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_patch or applypatch, as Codex 0.163.0 makes them on macOS) to the codex executable the Node resolves from its PATH. 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 stress reads no key: it logs each Node in to a loopback fake gateway with a fixed offline key, refuses a Node unless its lab-stress Connection 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 a maia.metrics/v1 lab snapshot; 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/node stays one universal tarball. It packs the darwin-arm64 and linux-x64 process 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.yml compiles the Linux host on GitHub-hosted ubuntu-24.04 from 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 verify refuses 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 main can be neither deleted nor force-pushed. Two GitHub rulesets enforce this with no bypass (25688154, 25688193); the pre-push hook and pnpm dev:doctor give 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 bootstrap is 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 for YES <preview-id> on /dev/tty and 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 retryable saturated error 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 bootstrap is the one CLI command that starts a Node and stops it when the command ends. It needs a POSIX controlling terminal and maia-node on PATH. A Node already running for the profile refuses with bootstrap_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|status are offline commands of the Node executable, next to state check. On darwin-arm64, one machine:
    • install takes an exact nightly or canary version, or a channel name that npm resolves to one, and builds versions/<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.
    • use switches the selected version for one explicit, stopped profile. It previews by default and applies with --yes or the digest of the exact plan. The target build runs its own state check first, and only absent or readable switch; migrates, newer, diverged and unreadable refuse and name the verdict. The build must also declare the start guard in that check. A home serves one profile: the first use binds it in current, 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 the current pointer commits a switch. The launcher in bin/ checks the file before it runs a build, and every Node that runs from a versions/<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.json asset 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, as remote launches 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 -f for the --dir argument), 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 for F_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_user remains 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 to actor_local_user. receipt list --actor is 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_user to 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 SecretStore Effect service has three backends: memory for tests, today's owner-private files and the macOS Keychain through /usr/bin/security -i. put creates and never replaces; the service has no generic delete or replace. A profile chooses managed storage once, with MAIA_SECRET_STORE=managed, on macOS, while it holds no Connection. The Node writes secret-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 reports kept and never overwrites a valid first writer. Each put first runs show-keychain-info on the named keychain: in a GUI login session security stores an item in the default keychain, and exits 0, when the named file no longer exists. The process manager admits /usr/bin/security for the new secret-store owner in the vouched_runtime tier only in its fixed envelope: arguments exactly -i, an empty environment, piped stdin, working directory / and a secret_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: -U upserts 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 is recovery-required for 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 and managed is 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 update is the first RL4 slice. It takes a version, nightly or canary, a --profile, and --yes or --confirm <id>:<digest>. It is offline like D-137. release use keeps 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 use plan and writes updates/<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. --yes previews 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, marked superseded when 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 --yes or its confirmation, or named in a refusal. Only a request without unfinished work resolves a channel and stages. A release use that 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_failed and leaves the update resumable.
    • migrates stays refused. The slice has no migration state and no process attestation.
  • 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. planned records are never pruned. A home that holds an applying record 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.list stays 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 list is 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 pins StrictHostKeyChecking=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: BatchMode alone still accepts a new key when the user's configuration says accept-new or no.
  • 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. A fleet command root, which CLI principle 9 forbids and node list already covers. A second host list. An all target for mutations. A channel field: 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: BatchMode already refused an unknown key under ssh's default. A configuration that set accept-new or no now 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: a ProxyJump hop, a ProxyCommand and a custom UserKnownHostsFile stay 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 what ssh printed. 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 the journalctl command 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 in 2023.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 where loginctl needs 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 as manifest_invalid, and the probed text serves the compare-and-set alone. remote probe --json, remote install --json and a blocked failure print one summary of the facts (RemoteNodeHostFacts), never the raw facts; the same two commands and remote rollback --json print 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: its version must be one Maia builds, and an unavailable frame shows Maia's sentence for its reason, never its message. 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's message is 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 --json line escapes them. One CLI function does the replacing for the human formatters (cell for a line or a cell, block for a patch or a reply that keeps its tabs and line breaks on purpose). A formatter applies cell to each field it prints, so a cell field cannot end its line or start a cell; a block field can still hold both. Human session attach follows the same rule: a command, a tool name and its arguments, and a file path are cell fields; the rest of each frame's text passes block once. block replaces 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 ## turn or [exit line 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); --json gives the exact text. The IDs and enums it prints stay as the protocol schema bounds them. The interactive sign-in shows its own ssh -t session. 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 --host and fleet status design 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 name actor_node-automation_routine-scheduler_<routine ID>, never actor_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 left launching by a crash resumes as started only when the turn named <fire ID>.send lives in the Session the fire attached and its receipt is a turn.send by that routine's scheduler actor; a person who sends that command ID elsewhere changes nothing. Any other fire becomes failed with outcome_unknown; it is never relaunched. routine disable is terminal and, like create, previews, then a person confirms (an agent, as D-178 configures); pause and resume are 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 launching fire; 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: resume schedules 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.start on a full Node refuses with saturated, as runtime admission does. The fire stays launching as 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 it saturated, 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 with paused: true inserts 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 as overlap until 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 the maia help --json budget 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-code and codex adapters 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.mjs keeps 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 detect reports, per installed CLI, three separate facts, each allowed unknown: the installation and approval, whether the approved build still launches, and freshness (installed against the newest version a public registry names). connection detect --refresh asks 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, or maia integration plan when a newer build is installed but not approved. The person picks notify (default) or off per CLI with connection update-policy set, through preview, confirmation and a receipt that names the authenticated actor; the policy lives on the active integration authorization, survives a manual integration apply and ends with integration 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 list command; running npm or brew; 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.send queueing, turn.queue.list and turn.cancel. Steering a running turn moves to S26b: turn.send has no mode or expectedTurnId. Claude Code refuses steer: in a stream-json probe (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.put and artifact.get with 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, --container in the CLI). Fail closed: an account no grant offers is refused at Session admission with configuration_invalid naming maia connection grant set.
  • Pools: one pool per scope in mode off, single (one member) or rotate (1–32 members). Members are Connections already granted to that scope. A Project pool overrides its Workspace pool (D-022); off defers to the next scope. rotate reuses 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 --connection bypasses the pool but still needs a grant. Maia never rewrites auth.json or 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 stop ends 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_granted error (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 mutable all scope; 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.
    • off keeps 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.created schema change.
  • CLI size pins: first measured on 7ddfdf09 plus S2 (standalone 361,640 B, pack 463,847 B, help 70,248 B), then re-measured after merging main with its later slices, in a release-style build with MAIA_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's turn queue and turn cancel leaves 681 B.
  • Consequences: resolveConnectionChoice is 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.ts imports effect/ai/McpProtocol, effect/ai/McpSchema and effect/ai/McpServer directly. The barrel effect/ai re-exports every MCP protocol version, and the bundler cannot prune a namespace import, so the standalone carried five protocol versions while maia mcp serves 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 makeCli and runCli is 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 mcp is 131,715 minified bytes (46 modules); Effect's McpServer module 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=managed on a fresh Linux profile writes the marker {backend: "systemd-creds", directory, storeId, version: 1} (an absolute directory of 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, and get runs the matching decrypt. put seals first, then publishes with an exclusive temporary file, fsync, link (which never replaces), removal of the temporary name and a directory fsync, and reports stored or kept after 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 before kept; 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 as unsafe and 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 later put or get rejects a malformed file or one sealed for another account, and accepts a valid copy sealed for the same account, so a later put can return kept. 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 with O_NOFOLLOW | O_NONBLOCK and 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 with O_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 no openat or linkat, 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 as systemd 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-creds for the secret-store owner alone, in the hard_containment tier, with exactly three argument lists (--version, and the encrypt and decrypt above), 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-creds ships with systemd, adds no dependency and needs no helper.
  • Rejected: --with-key=auto, which picks a TPM key where one exists while tpm2 fails in user scope, so host keeps one meaning on every host; omitting --name, which turns the tool's name check off; Keychain-style machinery for an uncertain write, because put creates 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 secret recovery-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-creds needs 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 through systemdCredsStoreFor, 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/cc or /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 under systemd-run --user --scope), where the helper refuses every launch with cgroup_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 vouched systemd-creds and 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 encrypt created the host key (root:root, mode 0400) and returned base64 text; a decrypt with --name=other failed with "Name in credential doesn't match expectations"; a decrypt without --name succeeded with a warning; a flipped or truncated credential failed with "Bad credential format"; encrypt without --user failed with InteractiveAuthenticationRequired; --with-key=tpm2 failed 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.ts joined the "Secure filesystem" allow-list in scripts/eslint/effect-boundaries/policy/policy.mjs: it needs the node:fs no-follow opens (credential and directory) and lstat, which Effect's FileSystem lacks. The directory opener sits behind a Context.Reference so 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_BYTES in apps/cli/src/bin/maia.test.ts rises 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 CLI composite leaf 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() or block(). Every action prints its CLI equivalent first, in the stdin form so no body appears, and its receipt ID after.
    • Renderer: @earendil-works/pi-tui at 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 with next: 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-core is to be published as the sixth npm package (D1-prep P4), prerelease only and external in the CLI, for apps/client and the CLI. The owner published the placeholder 0.0.0-canary.placeholder.1 and 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. --plain prints one Tree snapshot, reads no input and offers no action. Under MAIA_SESSION_CREDENTIAL it 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 that tmux show-options reports; 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).
  • 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-agent already 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's 7ed6a423 changes 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; da4b38e7 follows 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/agent already resolves the same @earendil-works/pi-tui@1.0.0 through pi-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.show is that contract's read, and session.goal.set and session.goal.clear are 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 /goal into 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 queued run 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 returns blocked rather than growing a queue silently; an entry that waits past its deadline shows as expired, and a fire still launches at most once. A Routine's cron gains if-busy: queue | skip; skip is today's overlap rule. This amends D-142: a fire that finds the Node full waits in the queue instead of failing saturated at its 10-minute deadline. The same owner decision sets three shapes, each in its owning slice: a cron's session: new | same is Routine target.mode start | wake, in the Routine slice that ships wake after S26; an Agent's sessions: single | multiple needs a Node-enforced exclusive cardinality, with no slice yet, after S49, and no client shows single before 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 saturated at 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 QUE and WAIT codes 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) or workspace. Two Projects of one organizational Workspace reach each other only when both set workspace. A person changes it with maia 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.list and 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.
    • 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.list shows 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 all setting.
  • 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 --node for any registered Node), the worker MCP tool show_node_resources in 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_resources line 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-only question, 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.send and 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 content level 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 verify reports 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, outputDigest and request_digest included, 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 reports digest_key_unavailable and 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 skills CLI 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/skills and ~/.maia/skills leave the target layout.
    • Maia reads skills list --json and 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.status reports each Skill as current, outdated, modified, missing, unpinned or unchecked, for each Project, Node and harness, with the time of the last check and the Sessions still on an older digest.
    • skills.check runs the CLI's update in a scratch copy and compares the scratch lock with the real one. It never runs at launch.
    • skills.update.preview and skills.update.confirm run 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.
  • 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 agent keeps 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.
  • 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-server thread/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 .maia on its own.
    • Claimed when it ends. When the harness's evidence shows a session ended (Claude's sessions/<pid>.json is 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 with adoption_refused while 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.preview and session.adopt.confirm are Deprecated once S11b ships; until then maia session adopt is the manual claim.
  • 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 .maia with 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 stays noun.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 as process.start are not valid tool names for the major model APIs.
    • Namespaces: __ separates a namespace from a tool. Maia's catalogue tools are maia__…, a prefix nothing else may use; a Connection's or tool server's tools are <connection>__<tool>. An MCP client adds its own prefix, so maia mcp serves 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, limit 50 by default and at most 200, outputMode files_with_matches, content or count, and truncated in the result), web_fetch (url), web_search and ask_question. A tool with other semantics keeps its own verb-first name: Maia's argv-only execution.shell.run is run_command, never bash, because it runs no shell. Vendor harnesses keep their own built-in names; the trace maps each to its item kind.
    • Definition: a description written for the model (what the tool does and when to use it), an inputSchema in JSON Schema 2020-12 generated from the protocol schema, an outputSchema where the result is typed, and optional fields left optional.
    • Results and errors: results are JSON and bounded, with truncated when 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) with message, retryable and an optional hint the 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, cancelled or interrupted.
    • 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_skills lists eligible Skills and maia__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 skills CLI (D-170); events use lowercase family.verb with camelCase fields.
  • 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_list reads noun first, and dotted names fail model APIs); the bash name for an argv tool (one name, two meanings); a maia_ prefix on MCP tools (the client namespaces them already); renaming events from completed to settled (churn with no gain for a model).
  • Consequences:
    • TC1 renames the coordinator and worker MCP tools; maia-orchestration, the maia-worker Skill (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__tool is 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.
  • 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's turn_wait holds 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).
  • 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/updated carries modelContextWindow (its app-server schema, checked on 27 October 2026), and the pinned Claude Agent SDK 0.3.285 has getContextUsage, 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.
  • 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 after read its output. Exit 0 with no output ends the fire as skipped with reason gate_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.json reaches 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.
  • 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 maia CLI 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:
        • v and - split;
        • h j k l move focus;
        • z zooms and x closes the pane;
        • c opens a tab and 1–9 select one;
        • [ enters copy mode;
        • w lists Projects and g opens go-to;
        • d detaches 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 terminal commands.
      • 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.
    • 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 existing gap event (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 attach and 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 --node is 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 --node or an exact target merges every Node, and each row is labeled.
      • --node stays, 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.
  • 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 dashboard stays 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 disable and enable → TM9.
      • S21's ownership rules start with agent terminals (TM7).
    • Linux needs an interactive terminal tier like macOS's, and a shell allow-list in place of the /bin/zsh pin (TM1a).
    • Screen engine: the owner picks it from TM0's numbers.
  • 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 --node uses (BatchMode, strict host-key checking) and reads the greeting. --launcher names the bridge launcher; without it, the local Node reads the remote HOME once over the same arguments and greets install's default layout, ~/.maia/profiles/<profile>/bridge (profile default unless --remote-profile names one). The preview shows the absolute launcher, profile, instance and version. It refuses an untrusted host key and names ssh <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 name local with 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_changed when another instance, profile or version answers. It records the Node ready with origin registered and 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.remove forgets it and never reaches the host; --purge and node.remote.install under its name refuse, and login refuses because nothing is pinned.
    • Exposure. Human-only, like install and remove. A person answers y/N; a script pipes --json --dry-run into --confirm-stdin. There is no --yes.
  • Why: a Node a person started on a server (the dev lab, a host set up by hand) was unreachable: the registry reached ready only 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_registered and node_changed. The other refusals reuse install's transport_failed and storage_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.
  • 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 reports node.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.
  • 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 terminal suite'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.jsonc sets how far agents manage its Routines: full, propose or read.
      • The setting is read only from the Project's admitted .maia revision.
      • 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 full when TM9 ships.
    • 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 full when it confirms.
      • Roles on each Node later decide which people and agents may confirm (D-180).
      • disable stays 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 .maia allows;
        • an executor the agent's own grant permits.
      • 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 .maia admission in a trusted client. An agent may propose a change but never admits one. This bound stays under roles (D-180).
  • 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 full default 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-required stays for propose, read and 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 run arrives 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.
  • 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/protocol stays the only source of operations, schemas, errors, risk and exposure. A generator writes crates/protocol from 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_HOME layout 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 node suite, 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.
    • 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.toml workspace keeps every new crate under crates/, beside the pnpm workspace, with a pinned toolchain (rust-toolchain.toml). Desktop's Tauri crate stays in apps/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/node package 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.
  • 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.
  • 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_BYTES in apps/cli/src/bin/maia.test.ts raises 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 main at 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/node remains 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 in apps/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.
  • 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-sandbox filesystem 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 ~/.claude or a file the worker can edit:

    • Claude's Bash sandbox denyRead gains 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 Read of both paths and everything beneath them, and Edit of 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>/..canary stays 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-sources once and empty, so Claude loads no user, project or local settings. Claude still applies its inline --settings and 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 --settings once, whose JSON equals the sandbox Maia planned with no field added, removed or changed at any depth, and --disallowedTools once 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.
  • 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/fd reopen, or a link to the file or its folder;
    • Bash wrote neither its own tree's pids.max nor cgroup.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 /sys by 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.json and settings.local.json written 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.mjs that passed (node --test, 2 of 2).
  • 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 /sys write 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, /opt and /srv can be readable.
    • The Claude process's isolation is not established from other processes' /proc entries, 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 denyRead still 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.yml publishes one 0.1.0-canary.N for the newest green CI run of a push to main (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_ENABLED switch and the job that moved latest are removed from the workflow.
    • latest is frozen until the v0.1.0 public launch. Stable, next and nightly versions stay refused by the workflow.
  • 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 latest at 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 the latest promotion: they refuse a nightly version with a "canary builds only" error, and publish-npm.mjs has no promote command. 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 unemitted EventV1 target): ULID id (monotonic), per-Node seq, node, source, kind (<source>.<noun>[.<qualifier>…], existing kinds keep their names), ts, v, subject, optional command, a payload registered per kind (4 KiB by default for new public kinds). The registry lives in packages/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_events table, 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's synchronous; 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 returns cursor_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 flagged late). 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 = FULL is no longer mandatory; it is the default of events.fsync (always), with interval and never allowed.
    • 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.
  • 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_events types 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 unknown v.
    • 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 seq order and the monotone-key global merge order with its late flag.
    • 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 metrics and 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.show and approval.respond for approvals of its direct children only.
    • Only a question approval 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 answer skips the terminal rule only under a Session credential. approve and deny still require a person.
    • The external coordinator stays person-only until ORG1.
  • Why: The answer is the supervisor's job and stays attributable; the commands that change the machine stay with a person.
  • Rejected:
    • A new supervisor role type now: a second role system before ORG1.
    • Letting orchestrators approve: it hands them command authority.
  • 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-orchestration in the maia plugin, and maia-worker, injected at launch), and maia connection detect reports 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 --node and --json. A CI check compares every command and flag a Skill names with the CLI's command tree and fails on drift. It extends the maia-orchestration bundle check, which checks command names today, to flags.
    • Detection (AI1). maia agents detect --json lists the harnesses installed on a machine, their versions and their user-scope Skills folders (Project scope follows in AI1c), locally or on the Node --node names. Harnesses and versions come from the detection behind maia connection detect; Skills folders come from the skills CLI's agent list. Maia keeps no per-harness path table.
    • Install (AI2). maia skills install|update|uninstall|status (with --harness, --project or --user, --dry-run and --json) runs the pinned skills CLI 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-run shows the preview. maia setup and 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.
  • Clarification (AI1). The pinned skills CLI (1.7.0) has no command that lists agents or their folders; skills list --json lists 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 through npx: plain npx from a Project runs a skills binary and obeys a .npmrc that 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 reads SKILL.md inside 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 skills CLI keeps one pin and one per-harness path table, the reasons D-170 gives.
  • Rejected:
    • A Maia manifest beside the lock, or a Maia per-harness path table: two sources drift (D-170, D-040).
    • Skills written by hand without a drift check: they go stale as the CLI changes.
  • 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, and terminal events list for an unavailable terminal) adds an optional oldestRetainedCursor to its result when a prune removed events after the caller's cursor, so after + 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 show adds eventsPruned: true when 0 < eventCursor < the journal's oldest retained sequence; a live-terminal receipt stores 0 and is never marked. A live terminal keeps its own retention and reports an evicted range as an in-band gap event.
  • 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_BYTES in apps/cli/src/bin/maia.test.ts rises 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: main at b40ea1ec is 379,736 bytes and the AI0 change (#154) is 379,920 bytes, 184 more. The growth comes from one shared resolveCommand that the CLI and the guidance drift test both use.
    • The raise also covers the planned node status fields 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.
  • 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 skills CLI for agents.detect (AI1) and for installs (AI2). Plain npx or npm exec from a Project runs a skills binary the Project plants, and a runtime npm install trusts the host's npm, its configuration and its registry.
  • Decision (owner, 1 November 2026): skills is an exact-pinned runtime dependency of the Node package (1.7.0, no range). The Node resolves skills/package.json from its own module, reads bin.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 "through npx" 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 tar 7.5.22 and yaml 2.9.0 (both new to the lockfile) and four small packages tar needs.
    • The Node runs the CLI as a skills-listing process owner under the resource manager, on Linux hard containment (tree control; it does not isolate the filesystem). macOS and Windows report the scope as unavailable with reason unsupported. The vouch policy admits one skills target (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 as invalid_output and leaves the scope unavailable, never ok. 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 as output_too_large, and a burst the host cannot pass on fails closed as failed. More than 9 or more distinct folders for one harness (the protocol caps 8) also read as output_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_unknown and 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 detect against 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 then unavailable, never a startup failure.
    • The environment carries a fixed key list plus HOME; the credential keys, NODE_OPTIONS and NODE_PATH never 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 as output_too_large or fails closed, and a process that does not end cleanly is failed.
    • 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 reads unavailable with failed; no probe runs ahead and nothing starts uncontained. The supported kernel is in release and operations. It reads failed, not unsupported: 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_BYTES in apps/cli/src/bin/maia.test.ts rises 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_unavailable and upgrade_kernel in 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 appends session.activity.changed through 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, inImmediateTransaction and 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 kind events.read returns. 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.done and update.rolled_back go 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 current link. 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.
  • 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 with KillMode=process, a restart cleared the delegated subtree_control and the helper failed closed with containment_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.
  • 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-authority on session create, protocol field hostAuthority: true, with the vendor's own --permission mode (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 policy on-request, which overrides the user's Codex configuration. bypassPermissions is 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 with containment: none and shows it in create, show, its receipt and its session.created event, 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 with role_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=enabled or 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 with host_authority_disabled. A refusal fails that one Session; the others still recover. node status reports hostAuthority: enabled|disabled.
    • Visibility: the Session reports containment: none in show, list (text and JSON) and session.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) and containment: 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). No MAIA_* 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 existing vendor-sandbox mode), and with them Maia's vendor-sandbox profile, guard and MCP/feature overrides. sudo and setuid programs do not work inside the tree.
  • 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 cwd check 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 push and gh, 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: none label: its launch takes the host branch before any credential, MCP or gateway preparation.
    • Claude Code launches with the user's user, project and local settings sources, no Maia sandbox settings, hook guard, tool list, MCP config or plugin, and the caller's permission mode (the SDK's default when none). It uses a Claude Code Connection only: a system-default one leaves CLAUDE_CONFIG_DIR unset 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 own config.toml applies. thread/start and thread/resume both name the sandbox danger-full-access and the Session's approval policy (untrusted, on-request or never; on-request when none), and the launch fails closed unless Codex's response reports that policy and dangerFullAccess. on-failure is not a value: the 0.163 app-server rejects it, so session create refuses it. Only a system-default Connection runs a host Session; it leaves CODEX_HOME unset (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 never makes Codex ask for none. Under untrusted and on-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.preview and project.guard.confirm (digest-bound proof, compare-and-set revision, receipt). No new authority path. Its levels are none (default, today's behaviour), answer (answer question approvals) and decide (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.list and approval.show are unchanged: a coordinator already reads them. Only approval.respond is gated. The coordinator sends the coordinator mark (maia session approval approve|deny|answer --as-coordinator, or the approval_respond MCP 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_expired and approval_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). The approval.resolved event is committed with that receipt, carries the same actorId and the guardRevision in its payload and links to it by command ID.
    • A damaged approval record refuses a grant-based answer as role_denied and 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 or on enables; 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.status reports headlessApprovals: 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).
  • 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.respond through 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 supervisor role 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 decide grant: see above; D-186 stands.
    • Letting a Session or coordinator widen its own grant: D-180 forbids widening one's own access.
  • 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_BYTES in apps/cli/src/bin/maia.test.ts rises 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 owner label is not archived. The refusal is manager_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 label owner=<name>. An orchestrator child is not a manager, and a top-level orchestrator without an owner label 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 and session adopt (which takes the optional role, labels and title on 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 with settledReason. 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 archive takes an optional caller-written summary (up to 2 KiB) and transcriptPath. 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_at only 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.
  • 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 owner label 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 orchestrator records 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 (sessions has 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.
    • latest follows the newest canary. A separate latest job in .github/workflows/publish.yml moves the latest dist-tag of every public package to each canary that publishes, after the release is recorded. latest follows the canary tag and moves forward only. A default install of a package therefore gets the newest canary, as the canary tag does.
    • Only a real run of main publishes, and only its tip. A canary publishes from a push to main (or a dispatch from main) 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 of main, 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 take latest back. A refused plan skips the build and the publish and is not a failure. The publish, release and latest jobs each repeat the whole rule (not a dry run, and main) instead of trusting the jobs before them.
    • The hard block stays. publishedChannelOf, requireCanary, installableChannelOf, installableVersion and the release job still refuse any version that is not a -canary.N prerelease, and the new latest command 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 canary dist-tag, never latest or any other tag, and maia update has 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 latest was 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 canary tag, which the updater follows, and latest, so any repository writer could put unreviewed code on every default install.
    • Moving latest only for canaries built from main, with branch canaries still moving canary: 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 latest passes for older code. The guard compares commits instead.
    • Moving latest inside 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 latest at the newest canary no longer holds, because no stable release exists to protect. The first run after this lands proves the OIDC dist-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, the latest job fails and nothing else does; the packages not yet moved keep their latest, and a rerun of the job or the next canary moves them. The main-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's canary tag 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. The RELEASE_CHANNELS vocabulary and version.txt stay 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/site wording 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