Decision Mode

Mode URI: macp.mode.decision.v1 Status: permanent RFC: RFC-MACP-0007

Structured decision making with proposals, evaluations, objections, votes, and terminal commitment.

Runtime semantics: phase progression, value normalization, and commitment-readiness rules are defined in Runtime Modes § Decision Mode. Per-mode authorization and termination rules live in the protocol spec — see protocol modes. This page covers the SDK API.

When to use

Use Decision mode when multiple agents need to converge on a single outcome from a set of options. Common scenarios:

  • Selecting a deployment plan from multiple candidates
  • Choosing a vendor from shortlisted options
  • Approving or rejecting a design proposal
  • Any situation where agents propose, discuss, and vote

Participant model: declared

Participants are declared at SessionStart and fixed for the session's lifetime. Only declared participants can send mode-specific messages. The session initiator (typically the coordinator) can send any message type.

Determinism: semantic-deterministic

Same accepted envelope sequence → same semantic outcome. The vote counts, winner, and commitment will always be identical on replay. No external I/O or time-dependent logic is involved (beyond TTL).

Message flow

SessionStart
  ↓
Proposal (one or more options)
  ↓
Evaluation (analysis with recommendation: APPROVE|REVIEW|BLOCK|REJECT)
  ↓
Objection (concerns with severity: low|medium|high|critical)
  ↓
Vote (per-participant: approve|reject|abstain)
  ↓
Commitment → RESOLVED

The phases are advisory — the runtime does not strictly enforce phase ordering beyond basic structural rules. However, the projection tracks phase transitions for your orchestrator logic. Note that phase advances from "Proposal" to "Evaluation" as soon as the Proposal itself is applied, not when the first Evaluation arrives — so an on_phase_change("Evaluation", ...) handler fires one envelope earlier than its name might suggest.

Authorization & termination

Per-message authorization (who can send Proposal/Evaluation/Vote/Commitment) and the runtime's commitment-readiness checks are defined in Runtime Modes § Decision Mode. Additional governance rules — vote quorum, confidence thresholds, veto — come from policies bound at SessionStart; see Runtime Policy.

Session helper

from macp_sdk import AuthConfig, MacpClient, DecisionSession

# Per-agent auth configs
coordinator_auth = AuthConfig.for_dev_agent("coordinator")
alice_auth = AuthConfig.for_dev_agent("alice")
bob_auth = AuthConfig.for_dev_agent("bob")

client = MacpClient(
    target="127.0.0.1:50051",
    allow_insecure=True,  # local dev; production uses TLS by default
    auth=coordinator_auth,
)

session = DecisionSession(client, auth=coordinator_auth)
session.start(
    intent="pick a deployment plan",
    participants=["coordinator", "alice", "bob"],
    ttl_ms=60_000,
)

# Propose options
session.propose("p1", "deploy v2.1", rationale="tests passed, low risk")
session.propose("p2", "deploy v3.0-beta", rationale="new features ready")

# Evaluations
session.evaluate(
    "p1", "APPROVE", confidence=0.9, reason="stable release", sender="alice", auth=alice_auth
)
session.evaluate(
    "p2", "REVIEW", confidence=0.6, reason="needs more testing", sender="alice", auth=alice_auth
)

# Objections
session.raise_objection(
    "p2", reason="beta not validated in staging", severity="high", sender="bob", auth=bob_auth
)

# Votes
session.vote("p1", "approve", reason="safe choice", sender="alice", auth=alice_auth)
session.vote("p1", "approve", reason="agreed", sender="bob", auth=bob_auth)

# Check projection and commit
proj = session.decision_projection
winner = proj.majority_winner()
if winner and not proj.has_blocking_objection(winner):
    session.commit(
        action="deployment.approved",
        authority_scope="release-management",
        reason=f"winner={winner}, votes={proj.vote_totals()}",
    )

Projection queries

The DecisionProjection tracks all proposals, evaluations, objections, and votes locally:

proj = session.decision_projection

# Proposals
proj.proposals                    # dict[str, DecisionProposalRecord]
proj.proposals["p1"].option       # "deploy v2.1"

# Evaluations
proj.evaluations                  # list[DecisionEvaluationRecord]
proj.review_evaluations()         # list[DecisionEvaluationRecord] with recommendation == "REVIEW"
proj.qualifying_evaluations()     # list[DecisionEvaluationRecord] -- recommendation != "REVIEW"

# Objections
proj.objections                   # list[DecisionObjectionRecord]
proj.has_blocking_objection("p1") # True if any "p1" objection has severity "critical" (only)

# Votes
proj.votes                        # dict[proposal_id, dict[sender, DecisionVoteRecord]]
proj.vote_totals()                # {"p1": 2} -- only proposals that received a Vote appear as keys
proj.majority_winner()            # "p1" -- strict majority (>50%) of non-abstain votes, else None
proj.vote_ratio("p1")             # 1.0 -- APPROVE ratio of non-abstain votes on "p1"

# Lifecycle
proj.phase                        # "Proposal" | "Evaluation" | "Voting" | "Committed"
proj.is_committed                 # True after Commitment accepted
proj.commitment                   # CommitmentPayload or None
proj.transcript                   # list[Envelope] — accepted history as fed, deduplicated by message_id

# Anomalies -- discarded second Votes (see "First vote stands" below)
proj.anomalies                    # list[ProjectionAnomaly]
proj.has_anomalies                # True if any Vote for this session was discarded

First vote stands

Each sender may cast at most one Vote per proposal — the first vote stands and a second, distinct Vote from the same sender on the same proposal_id is discarded. Unlike Quorum mode (RFC-MACP-0011 §5 is silent on which of two ballots stands), RFC-MACP-0007 §5.3 says this directly: "the first accepted Vote stands."

Against a conforming runtime, a second Vote from the same sender on the same proposal never reaches the projection at all — the runtime rejects it and session.vote(...) returns an Ack with ok=False, so _send_and_track never calls apply_envelope for it. The discard-and-record behavior below is what runs when a projection is fed votes directly — a hand-built fixture, a captured/edited transcript, or any other non-runtime-mediated apply_envelope call:

proj.apply_envelope(first_vote_envelope)   # accepted -- alice's vote on "p1" is "approve"
proj.apply_envelope(second_vote_envelope)  # discarded -- alice already voted on "p1"

proj.votes["p1"]["alice"].vote   # "approve" (first vote stands)
proj.vote_totals().get("p1", 0)  # unaffected by the discarded second vote

proj.anomalies[-1].kind          # "duplicate_vote"
proj.anomalies[-1].sender        # "alice"
proj.anomalies[-1].subject_id    # "p1"
proj.has_anomalies               # True

A Vote naming a proposal_id this projection never saw a Proposal for is a different case — it is ignored entirely: no vote record, no phase advance, and deliberately no anomaly. Rejecting an unknown proposal_id is the runtime's obligation; a projection is a local, possibly partial view, and a mid-session joiner whose replay window starts after the Proposal legitimately never saw it. This is why the duplicate-vote case above records an anomaly (a known proposal, a real conflict) while this one does not (no way to tell a stale view apart from a fabrication).

Error cases

ErrorWhenHow to handle
FORBIDDEN on ProposalSender not a declared participantVerify sender is in participants list
FORBIDDEN on CommitmentSender is not the session initiatorOnly the coordinator should commit
SESSION_NOT_OPEN on VoteSession already resolved/expiredCheck session state before voting
DUPLICATE_MESSAGESame message_id sent twiceSafe to ignore (idempotent)

Real-world scenario: AI agent consensus

Three AI agents evaluate a security incident and decide on a response:

threat_analyzer_auth = AuthConfig.for_dev_agent("threat-analyzer")
impact_assessor_auth = AuthConfig.for_dev_agent("impact-assessor")
response_planner_auth = AuthConfig.for_dev_agent("response-planner")

session = DecisionSession(client, auth=coordinator_auth)
session.start(
    intent="respond to security alert SEC-2025-0042",
    participants=["coordinator", "threat-analyzer", "impact-assessor", "response-planner"],
    ttl_ms=300_000,  # 5 minutes
)

# Each agent proposes a response
session.propose(
    "p1", "isolate affected hosts", rationale="contain lateral movement",
    sender="threat-analyzer", auth=threat_analyzer_auth,
)
session.propose(
    "p2", "patch and monitor", rationale="known CVE, patch available",
    sender="response-planner", auth=response_planner_auth,
)

# Agents evaluate each other's proposals
session.evaluate(
    "p1", "APPROVE", confidence=0.85, reason="stops spread",
    sender="impact-assessor", auth=impact_assessor_auth,
)
session.evaluate(
    "p2", "BLOCK", confidence=0.3, reason="too slow for active exploit",
    sender="threat-analyzer", auth=threat_analyzer_auth,
)

# Agents vote
session.vote("p1", "approve", sender="threat-analyzer", auth=threat_analyzer_auth)
session.vote("p1", "approve", sender="impact-assessor", auth=impact_assessor_auth)
session.vote("p1", "approve", sender="response-planner", auth=response_planner_auth)

# Commit the consensus
session.commit(
    action="incident.response.selected",
    authority_scope="security-operations",
    reason="unanimous: isolate affected hosts",
)

API Reference

::: macp_sdk.decision.DecisionSession

::: macp_sdk.projections.DecisionProjection