Proposal Mode
Mode URI: macp.mode.proposal.v1
Status: provisional
RFC: RFC-MACP-0008
Offer/counteroffer negotiation with peer refinement. Designed for bilateral or multilateral negotiations where parties iteratively refine terms until convergence or terminal rejection.
Runtime semantics: convergence detection, counter-proposal supersession, and terminal-rejection handling are defined in Runtime Modes § Proposal Mode. This page covers the SDK API.
When to use
Use Proposal mode when agents need to negotiate terms through iterative offers and counteroffers:
- Contract negotiation (price, SLA, terms)
- Resource allocation (budget, capacity, scheduling)
- Configuration agreement (settings, parameters)
- Any bilateral/multilateral negotiation
Participant model: peer
Participants are symmetric peers — all declared participants have equal standing to propose, counter-propose, accept, or reject. There is no designated coordinator role for mode-specific messages (though Commitment still requires an authorized sender).
Determinism: semantic-deterministic
Same accepted envelope sequence → same negotiation outcome. Convergence, terminal rejection, and withdrawal states are fully determined by the message history.
Message flow
SessionStart
↓
Proposal (initial offer)
↓
CounterProposal (supersedes previous, iterative)
↓
Accept / Reject / Withdraw
↓
Commitment → RESOLVEDKey semantics
- CounterProposal mints a new proposal that references the one it supersedes
(
ProposalRecord.supersedes). The SDK projection does not retire the original: it staysstatus="open"and stays inlive_proposals()— see the note below the projection-query block. Only an explicitWithdrawsets"withdrawn". - Accept records a participant's acceptance of a specific proposal
- Reject with
terminal=Truesignals a final rejection — no further negotiation - Withdraw removes a proposal from consideration
- Convergence occurs when all participants accept the same live proposal
Authorization & termination
Per-message authorization, the configurable acceptance criterion (all_parties / counterparty / initiator), and counter-proposal round limits are defined in Runtime Modes § Proposal Mode. Override the criterion via a bound policy — see Runtime Policy.
Session helper
from macp_sdk import AuthConfig, MacpClient
from macp_sdk.proposal import ProposalSession
# Per-agent auth configs
coordinator_auth = AuthConfig.for_dev_agent("coordinator")
buyer_auth = AuthConfig.for_dev_agent("buyer")
seller_auth = AuthConfig.for_dev_agent("seller")
client = MacpClient(target="127.0.0.1:50051", allow_insecure=True, auth=coordinator_auth)
# The coordinator is a neutral convener, not a negotiating party, so it is
# deliberately left out of `participants`. Proposal mode's default
# acceptance criterion is `all_parties` (runtime: proposal.rs's convergence
# rule), which requires every declared participant to accept the same live
# proposal before commit is possible -- a non-voting coordinator in
# `participants` would make convergence unreachable. The initiator retains
# Commitment authority regardless of participant membership (RFC-MACP-0007 §2).
session = ProposalSession(client, auth=coordinator_auth)
session.start(
intent="negotiate service contract terms",
participants=["buyer", "seller"],
ttl_ms=120_000,
)
# Seller's initial offer
session.propose(
"p1", "Standard Package", summary="$100k/year, basic SLA", sender="seller", auth=seller_auth
)
# Buyer counter-proposes
session.counter_propose(
"p2", "p1", "Enhanced Package",
summary="$80k/year, premium SLA, 24/7 support",
sender="buyer",
auth=buyer_auth,
)
# Seller accepts the counter
session.accept("p2", reason="terms acceptable", sender="seller", auth=seller_auth)
# Buyer confirms
session.accept("p2", reason="agreed", sender="buyer", auth=buyer_auth)
# Commit the agreement
proj = session.proposal_projection
if proj.accepted_proposal() == "p2":
session.commit(
action="contract.agreed",
authority_scope="procurement",
reason="Both parties accepted p2",
)Projection queries
proj = session.proposal_projection
# Proposals
proj.proposals # dict[str, ProposalRecord] -- every proposal_id seen
proj.proposals["p1"].proposal_id # "p1"
proj.proposals["p1"].title # "Standard Package"
proj.proposals["p1"].summary # "$100k/year, basic SLA"
proj.proposals["p1"].sender # the Proposal/CounterProposal envelope's sender
proj.proposals["p1"].tags # list[str] (always [] for a CounterProposal)
proj.proposals["p1"].status # "open" | "rejected" | "withdrawn"
proj.proposals["p2"].supersedes # "p1" -- or "" for an original Proposal
proj.live_proposals() # dict[str, ProposalRecord] -- status != "withdrawn"
proj.active_proposals() # list[ProposalRecord] -- status == "open" only
proj.latest_proposal() # ProposalRecord or None -- last one inserted
# Accepts -- an Accept is recorded here, never on a ProposalRecord's status
proj.accepts # list[ProposalAcceptRecord] (proposal_id/reason/sender)
proj.accepted_proposal() # proposal_id if all senders' latest accepts agree, else None
proj.is_accepted("p2") # True if p2 is some sender's current (latest) accept
# Rejections -- only a terminal Reject changes a proposal's status
proj.rejections # list[ProposalRejectRecord]
# (proposal_id/reason/sender/terminal)
proj.has_terminal_rejection() # True if any rejection has terminal=True
proj.is_terminally_rejected("p1") # True if p1 specifically was terminally rejected
# Lifecycle
proj.phase # "Negotiating" | "TerminalRejected" | "Committed"
proj.is_committed # True after CommitmentAn
Acceptnever changes a proposal'sstatus. Acceptance lives inproj.accepts,accepted_proposal()andis_accepted()only — a proposal every party has accepted still readsstatus == "open". Likewise, a non-terminalRejectis recorded inproj.rejectionsbut leavesstatus == "open"; onlyterminal=Truesets"rejected". The three values aProposalRecord.statuscan ever hold are"open","rejected"and"withdrawn".This mirrors the protocol's own state model rather than omitting something: the runtime's proposal disposition is
LiveorWithdrawnonly, with acceptance held in a separate per-sender map, andtests/conformance/proposal_happy_path.jsonshows a proposal that every party accepted and that was then committed still readingLive. Acceptance is a per-sender, supersedable relation (RFC-MACP-0008 §5 rule 5), which §7 treats as a derived set distinct from the live-proposal set — so it is tracked per sender, not denormalized onto a record. (Issue #112.)
live_proposals()andactive_proposals()are not the same query.live_proposals()returns a dict of everything not"withdrawn"— which still includes proposals already marked"rejected".active_proposals()returns a list of only those still"open". A counter-proposal's superseded original appears in both, since aCounterProposaldoes not change the original's status.
accepted_proposal()does not verify participant coverage. It compares only the senders who have actually accepted, so it returns aproposal_idas soon as those senders agree — even if one of three declared participants has accepted and the other two are silent. Enforcing "all declared participants accepted" (theall_partiescriterion described under Authorization & termination) is the runtime's job at commit time, and the orchestrator's if it wants to gate earlier; it is not what this helper checks.
proj.proposals,live_proposals(),active_proposals()andlatest_proposal()hand back the projection's own live record objects, not copies. Treat them as read-only — mutating a returnedProposalRecordmutates the projection's internal state directly (same asTaskProjection's equivalent accessors).
A terminal
Rejectnaming aproposal_idthis projection never saw records the rejection but does not movephase.proj.has_terminal_rejection()andproj.is_terminally_rejected(proposal_id)still returnTrue— they readproj.rejections, notphase— butproj.phasestays"Negotiating", because the projection has no record to terminalize. This matters beyond bookkeeping:"TerminalRejected"is a terminal phase forParticipant.run(), so moving into it for an unknownproposal_idwould end a live event loop for a session that never actually terminated. (Issue #119.)
Error cases
| Error | When | How to handle |
|---|---|---|
FORBIDDEN | Sender not a declared participant | Verify sender |
INVALID_ENVELOPE | CounterProposal references non-existent proposal | Check supersedes_proposal_id exists |
SESSION_NOT_OPEN | Negotiation already concluded | Check session state |
Real-world scenario: multi-round negotiation
# Per-agent auth configs
vendor_auth = AuthConfig.for_dev_agent("vendor")
client_auth = AuthConfig.for_dev_agent("client")
# Round 1: Initial offers
session.propose("p1", "Plan A", summary="$50k, 6-month term", sender="vendor", auth=vendor_auth)
# Round 2: Counter
session.counter_propose(
"p2", "p1", "Plan A Revised", summary="$45k, 12-month term", sender="client", auth=client_auth
)
# Round 3: Final counter
session.counter_propose(
"p3",
"p2",
"Plan A Final",
summary="$47k, 12-month, quarterly reviews",
sender="vendor",
auth=vendor_auth,
)
# Both accept the final version
session.accept("p3", sender="client", auth=client_auth)
session.accept("p3", sender="vendor", auth=vendor_auth)
# At this point, proj.accepted_proposal() == "p3"API Reference
::: macp_sdk.proposal.ProposalSession
::: macp_sdk.proposal.ProposalProjection