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 → RESOLVED

Key semantics

  • CounterProposal mints a new proposal that references the one it supersedes (ProposalRecord.supersedes). The SDK projection does not retire the original: it stays status="open" and stays in live_proposals() — see the note below the projection-query block. Only an explicit Withdraw sets "withdrawn".
  • Accept records a participant's acceptance of a specific proposal
  • Reject with terminal=True signals 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 Commitment

An Accept never changes a proposal's status. Acceptance lives in proj.accepts, accepted_proposal() and is_accepted() only — a proposal every party has accepted still reads status == "open". Likewise, a non-terminal Reject is recorded in proj.rejections but leaves status == "open"; only terminal=True sets "rejected". The three values a ProposalRecord.status can 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 Live or Withdrawn only, with acceptance held in a separate per-sender map, and tests/conformance/proposal_happy_path.json shows a proposal that every party accepted and that was then committed still reading Live. 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() and active_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 a CounterProposal does 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 a proposal_id as 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" (the all_parties criterion 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() and latest_proposal() hand back the projection's own live record objects, not copies. Treat them as read-only — mutating a returned ProposalRecord mutates the projection's internal state directly (same as TaskProjection's equivalent accessors).

A terminal Reject naming a proposal_id this projection never saw records the rejection but does not move phase. proj.has_terminal_rejection() and proj.is_terminally_rejected(proposal_id) still return True — they read proj.rejections, not phase — but proj.phase stays "Negotiating", because the projection has no record to terminalize. This matters beyond bookkeeping: "TerminalRejected" is a terminal phase for Participant.run(), so moving into it for an unknown proposal_id would end a live event loop for a session that never actually terminated. (Issue #119.)

Error cases

ErrorWhenHow to handle
FORBIDDENSender not a declared participantVerify sender
INVALID_ENVELOPECounterProposal references non-existent proposalCheck supersedes_proposal_id exists
SESSION_NOT_OPENNegotiation already concludedCheck 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