This document is the authoritative specification of the Multi-Agent Coordination Protocol (MACP) as implemented by macp-runtime v0.3. It describes every message type, every field, every validation rule, every error code, and every behavioral guarantee in narrative detail. Whether you are building a client, implementing a new mode, or auditing the protocol for correctness, this document is your reference.
- Protocol Version
- Core Concepts
- Protobuf Schema Organization
- The Envelope
- The Ack Response
- Structured Errors (MACPError)
- Session State Enum
- gRPC Service Definition
- RPC: Initialize
- RPC: Send
- RPC: GetSession
- RPC: CancelSession
- RPC: GetManifest
- RPC: ListModes
- RPC: StreamSession
- RPC: ListRoots, WatchModeRegistry, WatchRoots
- Message Type: SessionStart
- Message Type: Regular Message
- Message Type: Signal
- TTL Configuration
- Message Deduplication
- Participant Validation
- Session State Machine
- Mode System
- Decision Mode Specification
- Multi-Round Mode Specification
- Validation Rules (Complete)
- Error Codes (Complete)
- Transport
- Best Practices
- Future Extensions
Current version: 1.0
All messages must carry macp_version: "1.0". The Initialize RPC is the mechanism by which client and server agree on a protocol version. The server currently supports only "1.0" — if the client proposes only unsupported versions, the Initialize call returns a gRPC INVALID_ARGUMENT error.
Migration note from v0.1: The previous protocol used
macp_version: "v1". Version 0.2 uses"1.0". Old clients sending"v1"will receiveUNSUPPORTED_PROTOCOL_VERSION.
A protocol is a set of rules that all participants agree to follow. Just as HTTP defines how browsers and servers exchange web pages, the MACP protocol defines how agents exchange coordination messages: what information must be included, what order things must happen, what is allowed, and what is forbidden.
Without a formal protocol, different agents might format messages differently, state transitions could be inconsistent, errors would be ambiguous, and debugging would be nearly impossible. With MACP:
- Everyone speaks the same structured language.
- Behavior is predictable and deterministic.
- Tools and clients can be built for any MACP-compliant runtime.
- Audit logs are meaningful because every event follows a known schema.
The protocol is defined across three protobuf files, organized by concern:
proto/
├── buf.yaml # Buf linter config (STANDARD lint, FILE breaking)
└── macp/
├── v1/
│ ├── envelope.proto # Envelope, Ack, MACPError, SessionState enum
│ └── core.proto # Service definition, all request/response types,
│ # capability messages, session payloads, manifests,
│ # mode descriptors, streaming types
└── modes/
└── decision/
└── v1/
└── decision.proto # ProposalPayload, EvaluationPayload,
# ObjectionPayload, VotePayload
envelope.proto contains the foundational types that every message touches: the Envelope wrapper, the Ack acknowledgment, the MACPError structured error, and the SessionState enum. These are imported by core.proto.
core.proto contains everything else: the MACPRuntimeService definition with all ten RPCs, the request/response wrappers, capability negotiation messages (ClientInfo, RuntimeInfo, Capabilities and its sub-capabilities), session lifecycle payloads (SessionStartPayload, SessionCancelPayload, CommitmentPayload), introspection types (AgentManifest, ModeDescriptor), and streaming types.
decision.proto contains the mode-specific payload types for the Decision Mode: ProposalPayload, EvaluationPayload, ObjectionPayload, and VotePayload. These are not referenced by the core proto — they are domain-level schemas that clients use to structure their payloads.
The buf.yaml file configures the Buf linter with STANDARD lint rules and FILE-level breaking-change detection, ensuring the proto schema evolves safely.
Every message sent through the Send or StreamSession RPC is wrapped in an Envelope. The Envelope is the universal container — it carries both the routing metadata and the actual payload.
message Envelope {
string macp_version = 1; // Must be "1.0"
string mode = 2; // Coordination mode (e.g., "decision", "macp.mode.decision.v1")
string message_type = 3; // Semantic type: "SessionStart", "Message", "Proposal", etc.
string message_id = 4; // Unique ID for this message (used for deduplication)
string session_id = 5; // Session this belongs to (empty for Signal messages)
string sender = 6; // Who is sending
int64 timestamp_unix_ms = 7; // Informational client-side timestamp
bytes payload = 8; // The actual content
}Field-by-field narrative:
-
macp_version— The protocol version. The server checks this first. If it is not"1.0", the message is immediately rejected withUNSUPPORTED_PROTOCOL_VERSION. This is a hard gate — no further processing occurs. -
mode— The name of the coordination mode that should handle this message. Accepted values include RFC-compliant names (macp.mode.decision.v1,macp.mode.multi_round.v1) and backward-compatible aliases (decision,multi_round). An empty string defaults tomacp.mode.decision.v1. If the name does not match any registered mode, the message is rejected withMODE_NOT_SUPPORTED. -
message_type— Determines how the runtime routes the message. Three routing categories exist:"SessionStart"— creates a new session."Signal"— ambient message that does not require a session.- Everything else (
"Message","Proposal","Evaluation","Objection","Vote","Commitment","Contribute", etc.) — dispatched to the mode'son_message()handler within an existing session.
-
message_id— A client-chosen unique identifier. The runtime uses this for deduplication: if a message with the samemessage_idhas already been accepted for a given session, the runtime returnsok: true, duplicate: truewithout re-processing. Clients should use UUIDs or similarly unique values. -
session_id— Identifies the session. Required for all message types exceptSignal. ForSessionStart, this becomes the ID of the newly created session. For subsequent messages, the runtime looks up this session in the registry. -
sender— Identifies who is sending the message. If the session has a non-empty participant list, the sender must be a member of that list or the message is rejected withINVALID_ENVELOPE. -
timestamp_unix_ms— An informational timestamp set by the client. The runtime does not use this for any logic — it records its ownaccepted_at_unix_msin the Ack. This field exists for client-side tracing and ordering. -
payload— The actual content of the message, encoded as raw bytes. The interpretation depends on themessage_typeand the mode:- For
SessionStart: a protobuf-encodedSessionStartPayload(or empty bytes for defaults). - For Decision Mode messages: JSON-encoded payloads matching
ProposalPayload,EvaluationPayload, etc. - For Multi-Round
Contributemessages: JSON{"value": "<string>"}. - For
Signal: arbitrary bytes.
- For
Every Send call returns an Ack — a structured acknowledgment that provides complete information about what happened.
message Ack {
bool ok = 1; // true if accepted
bool duplicate = 2; // true if this was an idempotent replay
string message_id = 3; // echoed from the request
string session_id = 4; // echoed from the request
int64 accepted_at_unix_ms = 5; // server-side timestamp
SessionState session_state = 6; // session state after processing
MACPError error = 7; // structured error (if ok == false)
}Understanding the Ack fields:
-
ok— The primary success indicator.truemeans the message was accepted and processed.falsemeans it was rejected — consult theerrorfield for details. -
duplicate— Set totruewhen the runtime recognizes a previously-acceptedmessage_idfor the same session. The message is not reprocessed; the Ack simply confirms idempotent acceptance. This allows clients to safely retry without side effects. -
message_idandsession_id— Echoed back from the request for client-side correlation, especially useful in asynchronous or batched workflows. -
accepted_at_unix_ms— The server-side timestamp (milliseconds since Unix epoch) at the moment the message was accepted. This is authoritative — clients should use this rather than their owntimestamp_unix_msfor ordering guarantees. -
session_state— The session's state after the message was processed. This tells the client whether the session is stillOPEN, has beenRESOLVED(e.g., after aCommitmentmessage in Decision Mode), or hasEXPIRED. For messages that don't touch a session (e.g.,Signal), this isSESSION_STATE_OPEN. -
error— A structuredMACPErrorobject present whenok == false. Contains the RFC error code, a human-readable message, and optional correlation fields. See the next section for details.
Migration note from v0.1: The old
Ackhad onlyaccepted: boolanderror: string. The new Ack is significantly richer — clients should update to read the structurederrorfield and theduplicateandsession_statefields.
When a message is rejected, the Ack.error field contains a structured error:
message MACPError {
string code = 1; // RFC error code (e.g., "INVALID_ENVELOPE")
string message = 2; // Human-readable description
string session_id = 3; // Correlated session (if applicable)
string message_id = 4; // Correlated message (if applicable)
bytes details = 5; // Optional additional detail payload
}The code field uses a fixed vocabulary of RFC-compliant error codes (see Error Codes below). The message field provides a human-readable explanation. The session_id and message_id fields echo back the relevant identifiers for correlation. The details field is reserved for future use (e.g., structured error payloads for specific modes).
Session state is represented as a protobuf enum:
enum SessionState {
SESSION_STATE_UNSPECIFIED = 0;
SESSION_STATE_OPEN = 1;
SESSION_STATE_RESOLVED = 2;
SESSION_STATE_EXPIRED = 3;
}The UNSPECIFIED value is the protobuf default and should not be set intentionally. The runtime maps its internal SessionState enum (Open, Resolved, Expired) to these wire values.
The MACPRuntimeService is the single gRPC service exposed by the runtime:
service MACPRuntimeService {
rpc Initialize(InitializeRequest) returns (InitializeResponse);
rpc Send(SendRequest) returns (SendResponse);
rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse);
rpc GetSession(GetSessionRequest) returns (GetSessionResponse);
rpc CancelSession(CancelSessionRequest) returns (CancelSessionResponse);
rpc GetManifest(GetManifestRequest) returns (GetManifestResponse);
rpc ListModes(ListModesRequest) returns (ListModesResponse);
rpc WatchModeRegistry(WatchModeRegistryRequest) returns (stream WatchModeRegistryResponse);
rpc ListRoots(ListRootsRequest) returns (ListRootsResponse);
rpc WatchRoots(WatchRootsRequest) returns (stream WatchRootsResponse);
}Migration note from v0.1: The old service was named
MACPServicewith only two RPCs (SendMessageandGetSession). The v0.2 service isMACPRuntimeServicewith ten RPCs. TheSendMessageRPC has been replaced bySend(which wraps theEnvelopein aSendRequest).
The Initialize RPC is a protocol handshake that should be called before any session work begins. It negotiates the protocol version and exchanges capability information.
Request:
message InitializeRequest {
repeated string supported_protocol_versions = 1; // e.g., ["1.0"]
ClientInfo client_info = 2; // optional client metadata
Capabilities capabilities = 3; // optional client capabilities
}Response:
message InitializeResponse {
string selected_protocol_version = 1; // "1.0"
RuntimeInfo runtime_info = 2; // server name, version, description
Capabilities capabilities = 3; // server capabilities
repeated string supported_modes = 4; // registered mode names
string instructions = 5; // human-readable usage instructions
}Behavior:
- The server inspects the client's
supported_protocol_versionslist. - If
"1.0"is in the list, it is selected. If not, the RPC returns a gRPCINVALID_ARGUMENTstatus with a descriptive message. - The response includes the runtime's identity (
RuntimeInfowith name"macp-runtime", version"0.2.0"), its capabilities (sessions with streaming, cancellation, progress, manifest, mode registry, and roots), and the list of supported modes. - The
instructionsfield provides a brief human-readable note about the runtime.
Capabilities advertised:
| Capability | Value | Description |
|---|---|---|
sessions.stream |
true |
StreamSession RPC is available |
cancellation.cancel_session |
true |
CancelSession RPC is available |
progress.progress |
true |
Progress tracking is supported |
manifest.get_manifest |
true |
GetManifest RPC is available |
mode_registry.list_modes |
true |
ListModes RPC is available |
mode_registry.list_changed |
true |
WatchModeRegistry RPC is available |
roots.list_roots |
true |
ListRoots RPC is available |
roots.list_changed |
true |
WatchRoots RPC is available |
The Send RPC is the primary message ingestion point. It accepts a SendRequest containing an Envelope and returns a SendResponse containing an Ack.
Request:
message SendRequest {
Envelope envelope = 1;
}Response:
message SendResponse {
Ack ack = 1;
}Processing flow:
- Validate the Envelope — check
macp_version == "1.0", check thatsession_idandmessage_idare non-empty (except forSignalmessages wheresession_idmay be empty). - Delegate to the Runtime — the
Runtime::process()method routes toprocess_session_start(),process_signal(), orprocess_message()based onmessage_type. - Build the Ack — the server constructs a full
Ackwithok,duplicate, echoed IDs, server timestamp, session state, and any error.
All errors are returned in the Ack — the gRPC status is always OK for protocol-level errors. Only infrastructure-level failures (e.g., missing Envelope in the request) return non-OK gRPC statuses.
Retrieves metadata for a specific session.
Request:
message GetSessionRequest {
string session_id = 1;
}Response:
message GetSessionResponse {
SessionMetadata metadata = 1;
}
message SessionMetadata {
string session_id = 1;
string mode = 2;
SessionState state = 3;
int64 started_at_unix_ms = 4;
int64 expires_at_unix_ms = 5;
string mode_version = 6;
string configuration_version = 7;
string policy_version = 8;
}Behavior:
If the session exists, its metadata is returned — including mode name, current state (as a SessionState enum value), creation timestamp, TTL expiry timestamp, and the version fields from the original SessionStartPayload.
If the session does not exist, the RPC returns a gRPC NOT_FOUND status.
Migration note from v0.1: The old
GetSessionreturned aSessionInfowith fields likestate(as a string),resolution,mode_state, andparticipants. The new response usesSessionMetadatawith typedSessionStateenum and version metadata fields.
Explicitly cancels an active session, transitioning it to Expired state.
Request:
message CancelSessionRequest {
string session_id = 1;
string reason = 2;
}Response:
message CancelSessionResponse {
Ack ack = 1;
}Behavior:
- If the session does not exist, returns
ok: falsewithSESSION_NOT_FOUND. - If the session is already
ResolvedorExpired, the cancellation is idempotent — returnsok: truewithout modification. - If the session is
Open, logs an internalSessionCancelentry with the provided reason, transitions the session toExpired, and returnsok: true.
The cancellation reason is persisted in the session's audit log, providing a clear record of why the session was terminated.
Retrieves the agent manifest — a description of the runtime's identity and capabilities.
Request:
message GetManifestRequest {
string agent_id = 1; // currently unused
}Response:
message GetManifestResponse {
AgentManifest manifest = 1;
}
message AgentManifest {
string agent_id = 1;
string title = 2;
string description = 3;
repeated string supported_modes = 4;
repeated string input_content_types = 5;
repeated string output_content_types = 6;
map<string, string> metadata = 7;
}The response includes the runtime's identity ("macp-runtime", "MACP Coordination Runtime"), a description, and the list of supported mode names.
Discovers the coordination modes registered in the runtime.
Request: ListModesRequest {} (empty)
Response:
message ListModesResponse {
repeated ModeDescriptor modes = 1;
}
message ModeDescriptor {
string mode = 1;
string mode_version = 2;
string title = 3;
string description = 4;
string determinism_class = 5;
string participant_model = 6;
repeated string message_types = 7;
repeated string terminal_message_types = 8;
map<string, string> schema_uris = 9;
}Currently returned descriptors:
-
Decision Mode:
mode:"macp.mode.decision.v1"mode_version:"1.0.0"title:"Decision Mode"determinism_class:"deterministic"participant_model:"open"message_types:["Proposal", "Evaluation", "Objection", "Vote", "Commitment"]terminal_message_types:["Commitment"]
-
Multi-Round Mode:
mode:"macp.mode.multi_round.v1"mode_version:"1.0.0"title:"Multi-Round Convergence Mode"determinism_class:"deterministic"participant_model:"closed"message_types:["Contribute"]terminal_message_types:["Contribute"](the final Contribute that triggers convergence)
Bidirectional streaming RPC for real-time session interaction.
rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse);The client sends a stream of StreamSessionRequest messages (each wrapping an Envelope), and the server responds with a stream of StreamSessionResponse messages (each wrapping an echoed Envelope with an updated message_type reflecting the processing result). This enables real-time, interactive coordination without polling.
- ListRoots — Returns an empty list of
Rootobjects. Reserved for future resource-root discovery. - WatchModeRegistry — Server-streaming RPC for mode registry change notifications. Currently returns
UNIMPLEMENTED. - WatchRoots — Server-streaming RPC for root change notifications. Currently returns
UNIMPLEMENTED.
A SessionStart message creates a new coordination session.
Required fields:
message_type:"SessionStart"session_id: Must be unique — no session with this ID may already exist.message_id: Must be non-empty.mode: Must reference a registered mode (or be empty for the defaultmacp.mode.decision.v1).
Payload:
The payload should be a protobuf-encoded SessionStartPayload:
message SessionStartPayload {
string intent = 1; // human-readable purpose
repeated string participants = 2; // participant IDs (empty = open participation)
string mode_version = 3; // version of the mode to use
string configuration_version = 4; // configuration version identifier
string policy_version = 5; // policy version identifier
int64 ttl_ms = 6; // TTL in milliseconds (0 = default 60s)
bytes context = 7; // arbitrary context data
repeated Root roots = 8; // resource roots
}An empty payload (zero bytes) is valid — the runtime uses defaults (60s TTL, no participants, empty version strings).
Processing sequence:
- Runtime resolves the mode name (empty →
"macp.mode.decision.v1"). - Looks up the mode in the registry — rejects with
MODE_NOT_SUPPORTEDif not found. - Decodes the payload as a protobuf
SessionStartPayload— rejects withINVALID_ENVELOPEif decoding fails. - Extracts and validates TTL — rejects with
INVALID_ENVELOPEif out of range (see TTL Configuration). - Acquires write lock on the session registry.
- Checks for duplicate session ID:
- If the session exists and the
message_idmatches the session'sseen_message_ids, returnsok: true, duplicate: true(idempotent). - If the session exists with a different
message_id, rejects withINVALID_ENVELOPE(duplicate session).
- If the session exists and the
- Creates a session log and appends an
Incomingentry. - Calls
mode.on_session_start()— the mode may returnPersistStatewith initial mode state. - Creates a
Sessionobject with stateOpen, computed TTL expiry, participants, version metadata, and the message_id recorded inseen_message_ids. - Applies the
ModeResponseto mutate the session (e.g., storing initial mode state). - Inserts the session into the registry.
Any message with a message_type other than "SessionStart" or "Signal" is treated as a regular message dispatched to the session's mode.
Required fields:
session_id: Must reference an existing session.message_id: Must be non-empty.
Processing sequence:
- Acquires write lock on the session registry.
- Finds the session — rejects with
SESSION_NOT_FOUNDif not found. - Deduplication check — if
message_idis already in the session'sseen_message_ids, returnsok: true, duplicate: truewithout re-processing. - TTL check — if the session is
Openand the current time exceedsttl_expiry, logs an internalTtlExpiredentry, transitions the session toExpired, and rejects withSESSION_NOT_OPEN. - State check — if the session is not
Open(alreadyResolvedorExpired), rejects withSESSION_NOT_OPEN. - Participant check — if the session has a non-empty
participantslist and thesenderis not in it, rejects withINVALID_ENVELOPE. - Records
message_idinseen_message_ids. - Appends an
Incominglog entry. - Calls
mode.on_message(session, envelope). - Applies the
ModeResponseto mutate session state.
Signal messages are ambient, session-less messages. They are fire-and-forget coordination hints.
Special rules:
session_idmay be empty.message_idmust be non-empty.- No session is created, modified, or looked up.
- The runtime simply acknowledges receipt.
Use cases:
- Heartbeats between agents.
- Out-of-band coordination hints.
- Cross-session correlation signals (using the
SignalPayload.correlation_session_idfield).
Session TTL (time-to-live) determines how long a session remains open before it is considered expired.
Encoding: TTL is specified in the SessionStartPayload.ttl_ms field (protobuf int64).
ttl_ms value |
Behavior |
|---|---|
0 (or field absent) |
Default TTL: 60,000 ms (60 seconds) |
1 to 86,400,000 |
Custom TTL in milliseconds |
| Negative | Rejected with INVALID_ENVELOPE |
> 86,400,000 (> 24h) |
Rejected with INVALID_ENVELOPE |
TTL enforcement: TTL is enforced lazily — the runtime checks current_time > ttl_expiry on each non-SessionStart message. When expiry is detected:
- An internal
TtlExpiredlog entry is appended. - The session transitions to
Expired. - The message is rejected with error code
SESSION_NOT_OPEN.
There is no background cleanup thread — expired sessions remain in memory until the server is restarted. This is a deliberate simplification; future versions may add background eviction.
Migration note from v0.1: TTL was previously specified as a JSON payload
{"ttl_ms": <value>}. It is now a field in the protobufSessionStartPayload.
The runtime provides at-least-once delivery with idempotent acceptance via message deduplication.
Each session maintains a seen_message_ids: HashSet<String>. When a message arrives:
- If
message_idis already inseen_message_ids, the runtime returnsok: true, duplicate: truewithout re-processing the message or calling the mode. - If
message_idis new, it is added toseen_message_idsbefore processing.
This applies to both SessionStart and regular messages:
- SessionStart deduplication: If a
SessionStartarrives for a session that already exists and themessage_idmatches one in the session'sseen_message_ids, it is treated as an idempotent retry. If themessage_idis different, it is rejected as a duplicate session. - Regular message deduplication: If a regular message's
message_idmatches a previously accepted message for that session, it is returned as a duplicate.
This design allows clients to safely retry failed network requests without causing double-processing.
Sessions can optionally restrict which senders are allowed to contribute.
Configuration: The SessionStartPayload.participants field is a list of participant identifiers. If this list is non-empty, only senders whose name appears in the list may send messages to the session.
Enforcement:
- For regular messages (not
SessionStartorSignal), the runtime checks whetherenvelope.senderis insession.participants. - If the participant list is non-empty and the sender is not in it, the message is rejected with error code
INVALID_ENVELOPE. - If the participant list is empty, any sender is allowed (open participation).
Mode-specific behavior:
- In Multi-Round Mode, participants are essential — convergence is checked against the participant list. All listed participants must contribute for convergence to trigger.
- In Decision Mode, participants are optional — the mode works with or without a restricted participant list.
Sessions follow a strict state machine with three states and two terminal transitions:
SessionStart
│
▼
┌────────────┐
│ OPEN │ ← Initial state
└────────────┘
│ │
(mode returns (TTL expires or
Resolve or CancelSession)
PersistAndResolve)
│ │
▼ ▼
┌──────────┐ ┌─────────┐
│ RESOLVED │ │ EXPIRED │
└──────────┘ └─────────┘
(terminal) (terminal)
Transition rules:
| From | To | Trigger |
|---|---|---|
| Open | Resolved | Mode returns ModeResponse::Resolve or ModeResponse::PersistAndResolve |
| Open | Expired | TTL check fails on next message, or CancelSession RPC called |
| Resolved | — | Terminal — no transitions allowed |
| Expired | — | Terminal — no transitions allowed |
Once a session reaches a terminal state, any subsequent message to that session is rejected with SESSION_NOT_OPEN.
The Mode system is the heart of MACP's extensibility. The runtime provides "physics" — session invariants, TTL enforcement, logging, routing, participant validation — while Modes provide "coordination logic" — when to resolve, what intermediate state to track, and what convergence criteria to apply.
pub trait Mode: Send + Sync {
fn on_session_start(&self, session: &Session, env: &Envelope)
-> Result<ModeResponse, MacpError>;
fn on_message(&self, session: &Session, env: &Envelope)
-> Result<ModeResponse, MacpError>;
}Both methods receive immutable references to the session and envelope. They cannot directly mutate state — they return a ModeResponse that the runtime applies as a single atomic mutation.
pub enum ModeResponse {
NoOp, // No state change
PersistState(Vec<u8>), // Update mode_state bytes
Resolve(Vec<u8>), // Set resolution, transition to Resolved
PersistAndResolve { state: Vec<u8>, resolution: Vec<u8> }, // Both
}- NoOp — The mode has nothing to do. The message is accepted but no state changes.
- PersistState — The mode wants to update its internal state (e.g., record a vote, update a contribution). The bytes are stored in
session.mode_state. - Resolve — The mode has determined that the session should resolve. The resolution bytes are stored in
session.resolutionand the session transitions toResolved. - PersistAndResolve — Both state update and resolution in a single atomic operation.
The runtime registers modes by name in a HashMap:
| Key | Mode |
|---|---|
"macp.mode.decision.v1" |
DecisionMode |
"macp.mode.multi_round.v1" |
MultiRoundMode |
"decision" |
DecisionMode (alias) |
"multi_round" |
MultiRoundMode (alias) |
An empty mode field in the Envelope defaults to "macp.mode.decision.v1".
The Decision Mode (macp.mode.decision.v1) implements a structured decision-making lifecycle following RFC-0001. It models the flow from initial proposal through evaluation, optional objection, voting, and final commitment.
pub struct DecisionState {
pub proposals: HashMap<String, Proposal>, // proposal_id → Proposal
pub evaluations: Vec<Evaluation>,
pub objections: Vec<Objection>,
pub votes: HashMap<String, Vote>, // sender → Vote (last vote wins)
pub phase: DecisionPhase,
}
pub enum DecisionPhase {
Proposal, // Initial phase — waiting for proposals
Evaluation, // At least one proposal exists — accepting evaluations
Voting, // Votes are being cast
Committed, // Terminal — commitment recorded
}The Decision Mode accepts five message types, each with a corresponding protobuf payload type defined in decision.proto:
Creates a new proposal within the session.
Payload (JSON-encoded ProposalPayload):
{
"proposal_id": "p1",
"option": "Deploy to production",
"rationale": "All tests pass and staging looks good",
"supporting_data": "<base64-encoded bytes>"
}Validation:
proposal_idmust be non-empty — rejected withInvalidPayloadif empty.- A proposal with the same
proposal_idoverwrites the previous one.
Effect:
- Records the proposal in
state.proposals. - Advances the phase to
Evaluation(enabling evaluations and votes). - Returns
PersistStatewith the updated state.
Evaluates an existing proposal with a recommendation.
Payload (JSON-encoded EvaluationPayload):
{
"proposal_id": "p1",
"recommendation": "APPROVE",
"confidence": 0.95,
"reason": "Implementation looks solid"
}Validation:
proposal_idmust reference an existing proposal — rejected withInvalidPayloadif not found.
Recommendations: APPROVE, REVIEW, BLOCK, REJECT
Effect:
- Appends the evaluation to
state.evaluations. - Returns
PersistState.
Raises an objection against a proposal.
Payload (JSON-encoded ObjectionPayload):
{
"proposal_id": "p1",
"reason": "Security review not completed",
"severity": "high"
}Validation:
proposal_idmust reference an existing proposal — rejected withInvalidPayloadif not found.
Severities: low, medium, high, critical
Effect:
- Appends the objection to
state.objections. - Returns
PersistState.
Casts a vote on the current proposals.
Payload (JSON-encoded VotePayload):
{
"proposal_id": "p1",
"vote": "approve",
"reason": "Looks good to me"
}Validation:
- At least one proposal must exist — rejected with
InvalidPayloadif no proposals. - Cannot vote when phase is
Committed— rejected withInvalidPayload.
Votes: approve, reject, abstain
Effect:
- Records the vote in
state.votes, keyed by sender. If the same sender votes again, the previous vote is overwritten. - Advances the phase to
Voting. - Returns
PersistState.
Finalizes the decision and resolves the session.
Payload (JSON-encoded CommitmentPayload):
{
"commitment_id": "c1",
"action": "deploy-v2.1",
"authority_scope": "team-alpha",
"reason": "Unanimous approval"
}Validation:
- At least one vote must exist — rejected with
InvalidPayloadif no votes. - Phase must not already be
Committed— rejected withInvalidPayloadif so.
Effect:
- Advances the phase to
Committed. - Returns
PersistAndResolvewith the commitment payload as resolution bytes and the updated state. - The session transitions to
Resolved.
For backward compatibility with v0.1 clients, the Decision Mode also supports the legacy resolution mechanism: if the message_type is "Message" and the payload equals the bytes b"resolve", the session is immediately resolved with "resolve" as the resolution payload. This allows old clients to continue working without modification.
Any other Message-type payload returns NoOp.
Proposal received
│
┌──────────┐ ▼ ┌──────────────┐
│ Proposal │ ──────────────────→│ Evaluation │
└──────────┘ └──────────────┘
│ ↑
Vote │ │ Evaluation/Objection
received │ │ received
▼ │
┌────────┐
│ Voting │
└────────┘
│
Commitment received
│
▼
┌───────────┐
│ Committed │ (terminal)
└───────────┘
The Multi-Round Mode (macp.mode.multi_round.v1) implements participant-based convergence. A set of named participants each submit contributions, and the session resolves automatically when all participants agree on the same value.
pub struct MultiRoundState {
pub round: u64, // Current round number
pub participants: Vec<String>, // Expected participant IDs
pub contributions: BTreeMap<String, String>, // sender → current value
pub convergence_type: String, // "all_equal"
}The BTreeMap is used instead of HashMap for deterministic serialization ordering.
On SessionStart, the mode:
- Reads the
participantslist from the session (populated fromSessionStartPayload.participants). - Validates that the participant list is non-empty — returns
InvalidPayloadif empty. - Initializes the state with
round: 0,convergence_type: "all_equal", and empty contributions. - Returns
PersistStatewith the serialized initial state.
The mode processes messages with message_type: "Contribute".
Payload (JSON):
{"value": "option_a"}Processing:
- Deserializes the current
mode_stateintoMultiRoundState. - Parses the JSON payload to extract the
valuefield. - Checks if the sender's value has changed from their previous contribution:
- If this is a new contribution or the value differs from the previous one → increment the round counter and update the contribution.
- If the value is identical to the previous one → update without incrementing the round (no change in substance).
- Checks convergence: all listed participants have submitted at least one contribution, and all contribution values are identical.
- If converged → returns
PersistAndResolvewith:state: the finalMultiRoundStateserialized to JSON.resolution: a JSON payload containing:{ "converged_value": "option_a", "round": 3, "final_values": { "alice": "option_a", "bob": "option_a" } }
- If not converged → returns
PersistStatewith the updated state.
Non-Contribute messages return NoOp.
The only currently supported convergence strategy. Resolution triggers when:
- Every participant in the session's participant list has made at least one contribution.
- All contribution values are identical.
If any participant has not contributed, or if any two contributions differ, convergence has not been reached and the session remains open.
- Round starts at
0. - Each time a participant submits a new or changed value, the round increments by 1.
- Re-submitting the same value does not increment the round — this prevents artificial round inflation.
- The final round number in the resolution tells you how many substantive value changes occurred across all participants.
The following validation rules are applied in order. The first failing rule produces the error; subsequent rules are not checked.
IF macp_version != "1.0"
THEN reject with UNSUPPORTED_PROTOCOL_VERSION
This is checked in the gRPC adapter before any runtime processing.
IF message_type != "Signal":
IF session_id is empty OR message_id is empty
THEN reject with INVALID_ENVELOPE
IF message_type == "Signal":
IF message_id is empty
THEN reject with INVALID_ENVELOPE
(session_id may be empty)
IF message_type == "SessionStart":
Resolve mode name (empty → "macp.mode.decision.v1")
IF mode not in registered modes
THEN reject with MODE_NOT_SUPPORTED
IF message_type == "SessionStart":
Decode payload as protobuf SessionStartPayload
IF decode fails THEN reject with INVALID_ENVELOPE
Extract ttl_ms from payload
IF ttl_ms < 0 THEN reject with INVALID_ENVELOPE
IF ttl_ms > 86,400,000 THEN reject with INVALID_ENVELOPE
IF ttl_ms == 0 THEN use default (60,000 ms)
IF message_type == "SessionStart":
IF session already exists:
IF message_id matches existing session's seen_message_ids
THEN return ok=true, duplicate=true (idempotent)
ELSE reject with INVALID_ENVELOPE (duplicate session)
IF message_type is not "SessionStart" and not "Signal":
IF session does not exist
THEN reject with SESSION_NOT_FOUND
IF message_id is in session.seen_message_ids
THEN return ok=true, duplicate=true (idempotent)
IF session.state == Open AND current_time > session.ttl_expiry:
Log internal TtlExpired entry
Transition session to Expired
reject with SESSION_NOT_OPEN
IF session.state != Open
THEN reject with SESSION_NOT_OPEN
IF session.participants is non-empty AND sender not in session.participants
THEN reject with INVALID_ENVELOPE
Call mode.on_message(session, envelope)
IF mode returns Err(e) THEN reject with corresponding error code
ELSE apply ModeResponse
| RFC Error Code | Internal Error | When It Occurs |
|---|---|---|
UNSUPPORTED_PROTOCOL_VERSION |
InvalidMacpVersion |
macp_version is not "1.0" |
INVALID_ENVELOPE |
InvalidEnvelope |
Missing required fields, or invalid payload encoding |
INVALID_ENVELOPE |
DuplicateSession |
SessionStart for existing session (different message_id) |
INVALID_ENVELOPE |
InvalidTtl |
TTL value out of range (< 0 or > 24h) |
INVALID_ENVELOPE |
InvalidModeState |
Internal mode state cannot be deserialized |
INVALID_ENVELOPE |
InvalidPayload |
Payload does not match mode's expected format |
SESSION_NOT_FOUND |
UnknownSession |
Message for non-existent session |
SESSION_NOT_OPEN |
SessionNotOpen |
Message to resolved or expired session |
SESSION_NOT_OPEN |
TtlExpired |
Session TTL has elapsed |
MODE_NOT_SUPPORTED |
UnknownMode |
Mode field references unregistered mode |
FORBIDDEN |
Forbidden |
Operation not permitted |
UNAUTHENTICATED |
Unauthenticated |
Authentication required |
DUPLICATE_MESSAGE |
DuplicateMessage |
Explicit duplicate detection (distinct from idempotent dedup) |
PAYLOAD_TOO_LARGE |
PayloadTooLarge |
Payload exceeds size limits |
RATE_LIMITED |
RateLimited |
Too many requests |
Note that several internal error variants map to INVALID_ENVELOPE — this groups related validation failures under a single client-facing code while preserving distinct internal error variants for logging and debugging.
The protocol uses gRPC over HTTP/2:
- Binary protocol — efficient serialization via protobuf.
- Type-safe — schema enforcement at compile time.
- Streaming support — bidirectional streaming via
StreamSession. - Wide language support — gRPC clients available for Python, JavaScript, Go, Java, C++, and more.
- Built-in TLS — secure transport via standard gRPC TLS configuration.
Default address: 127.0.0.1:50051 (hardcoded in src/main.rs).
-
Always call Initialize first — Negotiate the protocol version and discover capabilities before sending session messages.
-
Check
Ack.okandAck.error— Don't just check the boolean; inspect theMACPError.codefor specific error handling. -
Use unique message IDs — UUIDs are recommended. This enables safe retries via the deduplication mechanism.
-
Handle duplicates gracefully — If
Ack.duplicateistrue, the message was already processed. Treat this as success. -
Send SessionStart first — Before any other messages for a session.
-
Respect terminal states — Once a session is
RESOLVEDorEXPIRED, don't send more messages. Cache the state locally. -
Use CancelSession for cleanup — Don't let sessions hang until TTL expiry if you know the coordination is over.
-
Use ListModes for discovery — Query available modes and their message types before creating sessions.
-
Use GetSession to check state — Useful for resuming after disconnection or verifying session state.
-
Declare participants when appropriate — Use the
participantsfield inSessionStartPayloadto restrict who can contribute, especially for convergence-based modes.
Currently, TTL is enforced lazily. Future versions will run a background eviction task.
Replay session logs to reconstruct state for debugging and auditing.
Query session event logs for audit trails.
majority— resolve when a majority of participants agree.threshold— resolve when N participants agree.weighted— resolve based on weighted votes.
Durable session state and log storage (e.g., to SQLite or Postgres).
Token-based authentication and role-based access control for sessions.
- Read architecture.md to understand how this is implemented internally.
- Read examples.md for practical code examples with the new v0.2 RPCs.