Skip to content

The frozen contract

internal/contract is the single seam shared by the control-plane and the sandbox. It is the only package both sides import, and it is frozen.

The freeze rule

Once the skeleton lands, neither agent may edit internal/contract/** unilaterally. Every file there carries the banner:

// FROZEN CONTRACT — do not edit without a joint RFC (see docs/contract.md).

A drift in this package is not a build error — it surfaces at runtime as a silent decrypt failure (mismatched cipher params) or a routing mismatch (mismatched row shapes). That is why the freeze is strict.

The RFC process

To change the contract:

  1. Write an RFC. Append a new dated section to the "RFC log" below describing the change, the motivation, and the migration impact on both trees.
  2. Get both CODEOWNERS to approve. The control-plane owner and the sandbox owner must both sign off. This is enforced by CODEOWNERS, which lists /internal/contract/ with both required reviewers.
  3. Land the contract change and both implementations together so the host and sandbox never compile against divergent types or crypto params.

Pinned crypto parameters (CipherScheme, CipherPageSize, KDFRawKey) and seq parity (host=even, sandbox=odd) are part of the contract and follow the same rule.

RFC log

RFC-0001 (applied): add OpenInboundRW + wire the encrypted-SQLite binding

Status: applied (owner sign-off). Adds OpenInboundRW and implements all four Open* helpers over the SQLite3/SQLCipher cgo binding (github.com/mutecomm/go-sqlcipher/v4). The whole tree now builds with cgo.

Motivation. The host control-plane is the sole writer of the inbound queue: the router enqueues platform messages (messages_in), upserts destinations, and records delivered for dedup. To do that it must open the inbound DB read/write. But internal/contract/crypto.go only exposes:

  • OpenInboundRO(path, k) — sandbox-side, read-only inbound;
  • OpenOutboundRW(path, k) — sandbox-side, read/write outbound;
  • OpenOutboundRO(path, k) — host-side, read-only outbound.

There is no host-side read/write inbound opener. internal/host/queue therefore cannot obtain a writable *sql.DB for inbound; its openInboundRW helper currently returns a pending error that references this RFC, and the real, parameterized SQL is written against *sql.DB so it activates the moment a real handle is provided.

Proposed change. Add to internal/contract/crypto.go:

// OpenInboundRW opens the inbound queue read/write (host side, sole inbound
// writer). It uses journal_mode=DELETE and the same raw-key discipline as the
// other openers, WITHOUT PRAGMA query_only (the host must write).
func OpenInboundRW(path string, k SessionKey) (*sql.DB, error)

It mirrors OpenOutboundRW's connection string and PRAGMA ordering exactly, minus query_only, so host and sandbox cannot drift on cipher params.

Migration impact.

  • Control-plane: internal/host/queue.openInboundRW switches from the pending error to calling contract.OpenInboundRW; no other host change. The router/delivery write paths then activate.
  • Sandbox: none. The sandbox never opens inbound read/write — the read-only-inbound type segregation is unchanged. This RFC does not weaken the type-level guarantee on the sandbox side.

As applied. internal/contract/crypto.go now opens encrypted databases with the per-session raw key carried in the DSN (_pragma_key), journal_mode=DELETE (writers), mode=ro + query_only (readers), and mmap_size=0 everywhere; the cipher page size is left at SQLCipher v4's default (== the pinned CipherPageSize, 4096). OpenInboundRW/OpenOutboundRW also ensure their schema. A round-trip test covers write→read, read-only write rejection, wrong-key failure, and absence of plaintext on disk. internal/host/queue.openInboundRW now calls contract.OpenInboundRW. The sandbox tree is unchanged except that the obsolete "binding pending" test now asserts the live binding; the read-only-inbound type-level guarantee is intact. ErrCryptoBindingPending is retained (no longer returned) so the sandbox tree keeps compiling. CI builds with CGO_ENABLED=1.

RFC-0002 (applied): pin the cross-seam wire formats (actions.go)

Status: applied. Adds internal/contract/actions.go; requires both CODEOWNERS' sign-off per the freeze rule (control-plane owner + sandbox owner).

Motivation. Three things cross the host↔sandbox seam but were not in the frozen contract — they lived informally in internal/host/delivery and internal/sandbox/tools, with the host defining them and the sandbox reverse-engineering them:

  1. the system-action envelope the sandbox writes as a KindSystem outbound body ({"action","payload","reason"}), which host delivery parses and re-authorizes;
  2. the schedule_task request body ({"action","prompt","run_at","recurrence"}) plus the named recurrence cadences (hourly/daily/weekly);
  3. the queue status vocabulary (queued, scheduled, processing, completed, delivered) — the host writes inbound status + the delivered marker, the sandbox writes the outbound acks, and each side reads the other's.

Because none of these were pinned, a rename on either side compiled cleanly and failed silently at runtime (a dropped system action, an unrecognized status) — exactly the drift class the freeze rule exists to prevent. Operationally it forced the sandbox to wait and observe the host's choices rather than build against a spec, serializing what should be parallel work (the last two sandbox commits were pure catch-up to host-defined formats; internal/sandbox/queue even carried a // Candidates to pin in the contract via RFC note).

Proposed change. Add internal/contract/actions.go with, all encoding/json + strings only (no new dependency):

  • type SystemAction struct { Action string; Payload json.RawMessage; Reason string } with MarshalSystemAction, ParseSystemAction (total — a bare body becomes an action name), and SystemActionName;
  • const ActionScheduleTask = "schedule_task" (the one action name that is not a ChangeKind; capability actions use string(ChangeKind));
  • type ScheduleRequest struct { Action, Prompt, RunAt, Recurrence string } with MarshalScheduleRequest / ParseScheduleRequest, and RecurrenceHourly/Daily/Weekly;
  • the StatusQueued/Scheduled/Processing/Completed/Delivered constants.

What stays out of the contract (deliberately). The authorization policy — which actions are privileged and which ChangeKind they map to (delivery.authorizeSystemAction) — remains host-internal. The sandbox must never be able to define what counts as privileged; the contract pins only the wire shapes, not the host's enforcement decision.

Migration impact (landed together).

  • Control-plane: delivery uses contract.SystemActionName, contract.ParseSystemAction, contract.ParseScheduleRequest, contract.ActionScheduleTask, and contract.StatusScheduled (local parseSystemAction and scheduleTaskPayload removed); scheduling aliases its recurrence constants to the contract; host/queue writes contract.StatusDelivered / StatusProcessing / StatusCompleted.
  • Sandbox: tools.CapabilityChange.SystemActionJSON marshals via contract.SystemAction (local hostSystemAction removed); sandbox/queue references the pinned status constants. No type-level guarantee changes.

The whole tree builds, vets, and tests green after the change.

RFC-0003 (applied): add the ask_user_question non-privileged system action

Status: applied (human-authorized this session; landed with both tree implementations together per the RFC process). Adds a second non-ChangeKind system action alongside schedule_task.

Motivation. An agent often needs a human decision mid-task ("which environment should I deploy to?") with a small set of choices — a choice card. There was no wire shape for this. Without one, a sandbox emitting an unrecognized action hits delivery.authorizeSystemAction's default case, which conservatively treats unknown actions as privileged and routes them through the gateway — the wrong behavior for a question, which mutates nothing and needs no approval. So the action must be pinned in the contract (like schedule_task) and recognized host-side as non-privileged.

Proposed change. Add to internal/contract/actions.go:

const ActionAskUser = "ask_user_question" // non-privileged, NOT a ChangeKind

type AskUserRequest struct {
    Action        string   `json:"action"`
    Question      string   `json:"question"`
    Options       []string `json:"options,omitempty"`
    AllowFreeform bool     `json:"allow_freeform,omitempty"`
}
func MarshalAskUserRequest(AskUserRequest) (string, error) // forces Action
func ParseAskUserRequest(string) (AskUserRequest, error)

It mirrors ScheduleRequest: a flat shape sharing only action with the SystemAction envelope, carrying ONLY a question + preset choices — no script/command field and no capability mutation — so it can never become an execution or escalation path. It is additive and backward-compatible (no existing shape changes).

Migration impact.

  • Control-plane: delivery.handleSystem special-cases ActionAskUser before the privilege routing (exactly as it already does for schedule_task) and records the question in a new in-memory internal/host/questions pending-question store; authorizeSystemAction gains an explicit non-privileged case for it. Feeding the human's answer back to the session as inbound is a follow-on; this RFC covers the wire shape + host-side tracking.
  • Sandbox: a new tools.AskUserQuestionTool (a HostForwarder, like ScheduleTaskTool) emits an AskUserRequest; it performs no privileged action.
  • Both land together, so neither tree compiles against a half-defined action.

The whole tree builds, vets, and tests green after the change.

RFC-0004 (proposed): agent-to-agent messaging + approval-gated create_agent

Status: APPROVED & LANDED (2026-06-16) — signed off by the sole CODEOWNER (@omerzamir / maintainer) via the decision to un-gate the a2a + create_agent change. Implemented (#20): the single contract edit (ChangeCreateAgent) plus the host-internal create_agent verifier/applier (internal/host/gateway/create_agent.go), the sandbox create_agent tool, and a2a routing with hop-depth + send-quota safety (internal/host/delivery/a2a.go). The maintainer's answers to the open questions below are recorded inline. Daemon composition (chaining the verifier/applier, wiring agent-destination grants, restricting create_agent approval to owners/admins via the PolicyApprover) is wired by the daemon.

Motivation. Two capabilities are missing: (1) an agent has no way to hand work to another agent group (agent-to-agent, "a2a"); (2) there is no way to create a new agent group at runtime under human control. Both are privileged, mutating operations, so they must respect the same trust boundary as every other control-plane mutation — never an unapproved escalation path.

Design principle — minimize the contract surface. Only what crosses the host↔sandbox seam belongs in internal/contract. Under that lens:

  • create_agent needs ONE new contract value: a new ChangeKind. It then rides the existing SystemAction envelope and gateway machinery — no new wire.
  • a2a needs ZERO contract change. The sandbox already addresses outbound targets by name via the send_message tool and the Destination rows; the host resolves a name to either a platform channel or an agent group. Routing a message to an agent group is therefore a host-internal decision. The Destination row already carries Type and AgentGroupID fields for exactly this.

Proposed contract change (the only frozen-file edit). Add to internal/contract/enums.go:

// ChangeCreateAgent provisions a NEW agent group. Privileged: always routed
// through the gateway's mandatory human-approval floor (a new agent is a new
// trust principal). The payload describes the proposed agent group; see the
// payload-conventions table.
ChangeCreateAgent ChangeKind = "create_agent"

create_agent payload convention (host-internal, == ChangeRequest.After, layered on the existing capability-change wire — action == "create_agent"):

{
  "name": "string",                         // required; human-readable
  "folder": "string",                       // optional; derived from name if absent
  "persona": {"instructions": "..."},       // optional initial persona
  "enabled_tools": ["..."],                 // optional
  "members": ["slack:alice", ...],          // optional initial access grants
  "wirings": [ { /* engage/session/scope */ } ]  // optional initial wirings
}

Host-internal design (NOT contract).

  • create_agent applier + verifier. delivery.authorizeSystemAction maps create_agent to ChangeCreateAgent (privileged → gateway). A new CreateAgentVerifier validates: name/folder carry no path traversal (..) or shell metacharacters; the derived AgentGroupID does not already exist; initial members/wirings are well-formed. The mandatory human floor always applies (a new principal is never auto-approved). On approval the applier calls registry.PutAgentGroup (+ optional initial wirings/members) — all existing Registry methods.
  • No privilege inheritance. The creating agent may only grant the new agent access it could already grant (scope check against the creator's roles); a new agent starts with the minimum capability set, never the creator's.
  • a2a routing. send_message to a destination whose Type == "agent" (with AgentGroupID set) is routed by delivery inbound to the target group via the existing router/session resolution, instead of to a platform adapter. Authorization reuses destination allow-listing: an agent may message only the agent groups it is explicitly permitted to (a new registry agent-destination check). Provenance is stamped via the existing MessageIn.SourceSessionID.
  • Loop / amplification safety. a2a carries a bounded hop depth (derived from SourceSessionID provenance) so messages cannot ping-pong indefinitely; per-agent send quotas bound fan-out. create_agent is rate-limited (pending-request cap) to prevent agent-bombing.

Migration impact (must land together per the freeze rule).

  • Contract: add ChangeCreateAgent (additive; no existing shape changes).
  • Control-plane: CreateAgentVerifier + applier; delivery agent-destination routing + authorizeSystemAction case; registry agent-destination storage + access check.
  • Sandbox: a create_agent tool (a HostForwarder, like the capability tools); a2a reuses the existing send_message tool unchanged.

Maintainer decisions (resolved 2026-06-16):

  1. Who may create agentsowners + admins. Every create_agent still hits the human floor; restricting the approver to owner/admin roles is done with the PolicyApprover (ApproverRoles{create_agent: [owner, admin]}), wired by the daemon. The CreateAgentVerifier hard-requires a human regardless, so create_agent can never be auto-approved even if misconfigured into a policy.
  2. a2a postureallow within a trust group, expressed as deny-by-default agent-destination grants the operator configures among trust-group members. The mechanism reuses the existing registry destination allowlist with the "agent" channel sentinel (IsAllowedDestination(sender, "agent", targetGroupID)).
  3. a2a hop-depth limit = 5; per-agent send quota = 120/min. Both are configurable via delivery.Delivery.WithA2ALimits.
  4. Kept host-internal. The "agent" sentinel / destination type is NOT pinned in the contract; the host resolves type. The contract stays minimal — the only frozen-file edit is ChangeCreateAgent.
  5. Landed together.

Capability-change payload conventions

These are cross-agent conventions layered on the frozen contract, not Go types in internal/contract. They define the payload the sandbox puts in a capability-change request and the ChangeRequest.After the host gateway verifiers inspect. The wire path is:

  1. Sandbox tool emits {"action":"<kind>","payload":<obj>,"reason":"..."} as the content of a KindSystem outbound message (action == the ChangeKind string, so it maps 1:1 to the host's authorizeSystemAction).
  2. Host delivery routes it to a gateway ChangeRequest with that Kind and After = the payload verbatim (see extractAfter).
  3. Gateway verifiers inspect After; then the mandatory human approves.

Per-kind payload shape:

Kind payload (== After) Verifier
packages {"apt":["..."],"npm":["..."]} (a flat ["..."] is also accepted) PackageNameVerifier rejects shell metacharacters
mounts [{"source":"/abs/host/path"}, ...] MountAllowlistVerifier rejects .. and out-of-allowlist sources
enabled_tools ["toolName", ...] none yet (human reviews)
persona {"instructions":"..."} none yet (human reviews)
wiring object (engage mode, pattern, scope, ...) none yet (human reviews)
permissions object (role/member grants) none yet (human reviews)
mcp_access {"server":"<name>","tools":["<tool>",...]} (omit tools = all the server's declared tools) MCPServerVerifier rejects an unknown server / a tool the server does not declare
skill_install {"skill":"<name>","version":"<version>"} host RESOLVES the named skill (curated source + minisign trust root); an unknown/unsigned/out-of-policy skill is refused before the gateway

RFC-0005: approval-gated MCP-server access — ChangeMCPAccess

MCP (Model Context Protocol) servers extend an agent with externally-served tools. The reference design wired them with a blind approval surface — the one gap IronClaw exists to close — so IronClaw runs every MCP server host-side and gates access through the gateway:

  • One new contract value: ChangeMCPAccess = "mcp_access" (this file's only MCP-access edit). It rides the existing SystemAction envelope (action == "mcp_access"); delivery.authorizeSystemAction maps it (privileged → gateway).
  • The human approves a NAMED server and NAMED tools — never "whatever the server exposes". The After payload names them; the approved grant is recorded on the agent group (registry.GrantedMCP) and materialized at launch.
  • MCP servers never run in the sandbox. A local server is a stdio subprocess on the host; a remote server is an HTTPS endpoint the host dials. Either way a per-session broker unix socket (/run/ironclaw/mcp.sock, like the model-proxy and egress sockets) exposes only the approved tool surface to the sandbox, which stays network=none and never speaks MCP itself. Credentials are injected host-side (${ENV} references resolved by the broker) and never reach the sandbox.
  • Kept host-internal. The MCP server catalog, the GrantedMCP shape, and the broker wire format are host-internal — not pinned in the contract.

RFC-0006: in-session skill install — ChangeSkillInstall

OpenClaw's headline loop is tell the assistant in chat to add a skill → a human approves → it executes in the same session. IronClaw already supported this shape for mcp_access; RFC-0006 closes the gap for skills, which were previously operator-only (out-of-session ironctl skill add / POST /v1/skills/install).

  • One new contract value: ChangeSkillInstall = "skill_install". It rides the existing SystemAction envelope (action == "skill_install"); the agent reaches it from chat via request_capability_change (kind skill_install).
  • The sandbox may only NAME a skill ({"skill","version"}) — it can never author skill content (persona text, tool grants, asset bundles). That invariant is the whole reason skills require operator-curated, minisign-signed bundles.
  • The host resolves through the SAME trust gate as the operator path. delivery.handleSkillInstall calls skills.InstallChange over the configured Resolver (curated source + trust root): fetch + signature-verify + manifest-validate. The resolved install is submitted as a ChangePermissions ChangeRequest — the skill_install kind is the sandbox→host action vocabulary only and is never itself a ChangeRequest.Kind — so the proven skill-install applier + respawn handle it exactly as the operator path, and the human approves the real persona/tools/egress it grants.
  • Fail-closed. With skills not enabled (no resolver), or a skill that is unknown/unsigned/out-of-policy, the proposal is refused host-side and never reaches the gateway. The sandbox can ask; only a curated+signed skill an operator provisioned can be proposed, and a human still approves it.

Kinds with no automated verifier still pass through the deterministic chain and the always-require-human floor; they are simply approved by a human without an extra machine check. New verifiers may be added later and only ever ADD rejections — never bypass the human floor.