Governance Policy
This page covers the runtime's implementation of the governance policy framework: how to register policies via gRPC, what rule schemas look like in practice, how the evaluation engine works internally, and how errors are surfaced. For the protocol-level policy specification -- identifiers, lifecycle, determinism guarantees, and the full rule schema definitions -- see the protocol policy documentation.
Managing policies
Policies are managed through five gRPC RPCs. Any authenticated sender can perform these operations.
| RPC | Purpose |
|---|---|
RegisterPolicy | Add a new policy to the registry |
UnregisterPolicy | Remove a policy (does not affect sessions already using it) |
GetPolicy | Retrieve a policy by its identifier |
ListPolicies | List all policies, optionally filtered by target mode |
WatchPolicies | Stream notifications when the registry changes |
The built-in policy.default is always present and cannot be registered or removed. The policy.std. namespace is reserved the same way -- see Reserved policy.std. profiles.
Registering a policy
Here is a complete example of registering a Decision Mode policy that requires majority voting with a confidence threshold:
{
"policy_id": "policy.fraud-review.majority-vote",
"mode": "macp.mode.decision.v1",
"description": "Require majority vote with 0.7 confidence threshold",
"schema_version": 1,
"rules": {
"voting": {
"algorithm": "majority",
"threshold": 0.5,
"quorum": { "type": "percentage", "value": 60 }
},
"evaluation": {
"required_before_voting": true,
"minimum_confidence": 0.7
},
"objection_handling": {
"critical_severity_vetoes": true,
"veto_threshold": 1
},
"commitment": {
"authority": "initiator_only"
}
}
}What registration checks
The runtime does not run a JSON-Schema evaluator: it carries no jsonschema dependency, and the canonical schemas/json/policy/*.schema.json documents live in the spec repository, not here. Registration instead applies three layers of hand-written checks, and only the constraints listed below are enforced. A rule the canonical schema forbids but this list does not name is accepted.
Known deviation: rule objects are not closed. RFC-MACP-0012 §4 requires a runtime to reject at admission any rules object carrying an undefined key at any nesting level ("rule objects are closed"); this runtime's deserialization step (below) instead defaults and silently ignores an unrecognized key, so a misspelled parameter (veto_threshhold for veto_threshold, say) registers as if the key were never supplied. This is a deliberate, accepted architectural tradeoff -- not running a JSON-Schema evaluator means there is no closed-object check to run -- tracked at #167. §4 also reserves keys matching ^[_$] (leading underscore or dollar sign) at every closed object level as an annotation namespace: they carry no governance semantics, an evaluator MUST ignore them, and per §8 two rules objects differing only in them are equal for replay and policy-identity comparison. This runtime's unknown-key tolerance means annotations already pass through today, but confers no protection against the deviation above -- a misspelled real parameter and a deliberate annotation are indistinguishable to it.
-
Deserialization. Rules must parse into the target mode's Rust struct. Every field has a default and unknown fields are ignored, so this catches type errors (a string where a number belongs), not missing or misspelled keys. Extension modes (
ext.*) and unrecognized mode names accept any JSON object. -
Value domains, mirroring the canonical schemas, copied from
schemas/json/policy/*.schema.jsonin the spec repo atSPEC_REV(.github/workflows/ci.yml) and pinned to it byregistry::tests::enum_lists_match_the_canonical_schemas, which runs in theconformance-oracleCI job (see Deployment for the local-reproduction warnings). Enum membership and numeric bounds:Constraint Rule voting.algorithmOne of none,majority,supermajority,unanimous,weighted,pluralityvoting.thresholdGreater than 0.0, at most1.0; at least0.5formajority, above0.5forsupermajorityvoting.weights[*]> 0, exclusive — a zero or negative weight is refusedvoting.weightsNon-empty whenever the key is supplied, whatever the algorithm voting.quorum.typeOne of count,percentage.n_of_mis not legal here, though the evaluator would accept itvoting.quorum.value>= 0(a number, not necessarily an integer);0is the vacuous participation floor atschema_version >= 3, see belowQuorum threshold.typeOne of n_of_m,percentage,count.weightedis refused — the canonical vocabulary no longer contains it, see belowQuorum threshold.valueA positive integer when the key is supplied ( > 0, exclusive); additionally<= 100whenthreshold.typeispercentageA wildcard (
"*") policy must satisfy every standards-track mode's schema and every mode's constraints above, not just Decision's, becauseSessionStartbinds it to every mode's sessions. Before this release it was validated against the Decision schema alone, which has no top-levelthreshold— so a Quorumthresholdinside a"*"policy was silently dropped at registration and then read, unchecked, by the Quorum mode. A"*"policy carrying an out-of-domainthresholdis now refused. Fields one mode's schema does not know are still ignored rather than refused, so a Decision-shaped wildcard (including the built-inpolicy.default) registers unchanged.The bounds at zero used to be inclusive, which made
voting.threshold: 0.0and an all-zerovoting.weightsmap degenerate but schema-legal; the runtime deferred the question upstream rather than deciding it at registration. Spec #99 settled it, and both bounds are now exclusive at zero indecision-rules.schema.json(exclusiveMinimum: 0, plusminProperties: 1onweights).threshold: 0.0made an all-REJECTround returnPassedunder bothmajorityandweighted; an all-zero or emptyweightsmap yielded "no votes" on a complete ballot set. Both are refused at registration now. Nothing is lost by the weights rule: theweightsmap is the weighted electorate, so a legitimately zero-weighted observer is expressed by omission from the map rather than by an explicit0. BecauseexclusiveMinimum: 0excludes negatives a fortiori, a mixed-sign map such as{"a": 1.0, "b": -1.0}is refused onband named in the error.threshold.valuefollows JSON Schema'sintegerkeyword, which matches any number with a zero fractional part:75and75.0are both accepted,75.5is not.The
thresholdfloor is unconditional and therefore reachesunanimousandplurality, which never read the field. That is deliberate per RFC-MACP-0012 §4.1 — athresholda policy author believed was in force should never be silently ignored. A rules object that omitsthresholdis unaffected, since the default is0.5. -
Conditional constraints. A
weightedvoting algorithm requires a non-emptyweightsmap; a suppliedweightsmap must be non-empty whatever the algorithm;majorityrequires a threshold of at least0.5andsupermajorityone above0.5— the asymmetry is deliberate, because the reservedpolicy.std.majorityprofile sets exactly0.5and RFC-MACP-0012 §2.2 pins it byte-identical on every runtime; anddesignated_rolecommitment authority requires a non-emptydesignated_roleslist.supermajorityadditionally requires thethresholdkey, not merely a legal value: RFC-MACP-0012 1.2.0-draft (spec #112) addedrequired: ["threshold"]to that arm, because without it{"algorithm": "supermajority"}validated and the schema's own0.5default then supplied a value the same arm forbids — a supermajority that was a bare majority wearing the name. This runtime already refuses it, and not by accident: the default is0.5and the check isthreshold <= 0.5, so the value the schema would have filled in is exactly the illegal one.majoritystays deliberately asymmetric — it constrains the value without requiring the key, since blocks that omitthresholdrely on the legal0.5default.
schema_version must be non-zero, and 0 is the only value registration rejects: RFC-MACP-0012 §3 constrains which versions a runtime supports at evaluation, not which ones the registry admits, and PolicyRegistry has never enumerated versions. The evaluator supports {1, 2, 3} and denies anything else at commitment time with unsupported policy schema version. The three are not interchangeable:
1— the original rule set.2— additive. It only signals that the descriptor may carry the Decision decline-gating fields (commitment.allow_decline_over_approval,objection_handling.critical_objection_action); the new fields are optional and default to legacy behaviour, so aschema_version: 1descriptor stays valid forever.3— the first semantic version. It changes how existing fields are evaluated rather than adding any: every voting algorithm other thannonebecomes binding on an empty decisive tally (see "No decisive votes" under Decision Mode below). The samerulesbytes therefore evaluate differently at2and at3. §3 SHOULDs that a new policy using a voting algorithm other thannonedeclare3.
Which semantics apply is a property of the stored descriptor, never of the runtime release (§8): two sessions started by the same binary, one binding a schema_version: 1 policy and one a schema_version: 3 policy, evaluate the empty tally differently, and versions 1 and 2 keep the legacy reading forever so stored sessions replay identically. Every rejection of the definition itself — including a policy_id under the reserved policy.std. prefix that is not the canonical definition (see below) — is reported with INVALID_POLICY_DEFINITION at the head of the message, because RegisterPolicyResponse carries no structured error code. A duplicate policy_id is the one rejection that carries no such prefix: the descriptor may be entirely valid and the only problem is that the id is taken, so it is a conflict rather than an invalid definition.
Both routes into the registry apply the same checks: the RegisterPolicy RPC and the MACP_POLICIES_DIR preload, which funnels through the same register path. "The same checks" means the same set for a given mode — as the Quorum rows above note, which checks run at all still depends on the policy's mode.
Validating a policies directory before startup
A MACP_POLICIES_DIR file that fails any check aborts startup, and loading stops at the first rejection. To check a directory without starting the server, run the binary with MACP_POLICIES_DRY_RUN=1:
MACP_POLICIES_DRY_RUN=1 MACP_POLICIES_DIR=/etc/macp/policies macp-runtimeIt reports every file by name — OK or REJECTED with the reason — and exits 0 if the directory would load, 1 otherwise. Nothing is bound, opened, or replayed. Run it before upgrading a runtime whose policies directory predates a release that tightened registration.
Rule examples by mode
Decision Mode
{
"voting": {
"algorithm": "supermajority",
"threshold": 0.67,
"quorum": { "type": "count", "value": 3 },
"weights": {}
},
"evaluation": {
"required_before_voting": true,
"minimum_confidence": 0.7
},
"objection_handling": {
"critical_severity_vetoes": true,
"veto_threshold": 1
},
"commitment": {
"authority": "initiator_only",
"designated_roles": [],
"require_vote_quorum": true
}
}Supported voting algorithms: none, majority, supermajority, unanimous, weighted, plurality.
Voting algorithm semantics
These are the rules the evaluator actually applies (RFC-MACP-0012 §4.1). This table is checked against the RFC prose every release, but §4.1 has absorbed three spec changes within this cycle alone (spec #99, #112, #122) -- canonical: RFC-MACP-0012 §4.1 + schemas/json/policy/decision-rules.schema.json at SPEC_REV, so diff against those if this table and a future spec revision disagree:
| Algorithm | Bar |
|---|---|
none | No voting constraint; the mode's built-in logic applies |
majority | approve / decisive >= threshold (default 0.5) |
supermajority | approve / decisive >= threshold; the schema requires threshold > 0.5 |
unanimous | Every declared participant cast an approve vote and no reject exists; threshold is not consulted |
weighted | Weighted approve share >= threshold; the weights map is the electorate, so a declared participant absent from it weighs 0 and is non-decisive |
plurality | More approve than reject; a tie fails; no threshold |
-
Denominator. For
majority,supermajorityandweightedthe denominator is the decisive votes -- those cast as approve or reject. Abstentions are excluded and neither help nor hinder the ratio. Underweighted, so are the ballots of participants outside theweightsmap -- see the next bullet. -
The
weightsmap is the weighted electorate. Underweighted, a declared participant absent fromweightshas weight0(an observer is expressed by omission; registration refuses an explicit0and an empty map). Such a ballot is accepted as a message and kept in history, but it is non-decisive: it contributes to neither side of the weighted ratio, it does not enter the decisive tally, and it does not satisfy the decline guard. It still counts as a vote cast for thevoting.quorumparticipation floor, which this rule leaves alone. A ballot set cast entirely by unlisted participants therefore has a total decisive weight of0, which is the empty decisive tally -- see the two "no decisive votes" bullets below. This rule is keyed on nothing: RFC-MACP-0012 §4.1 states it is "normative for every schema version", anddecision-rules.schema.jsonspells out "normative for EVERY schema version, not only 3". It is one of two rules in this release that can change how an already-stored session replays -- the other is the decline guard's reach onto aPassedround, two bullets below; §8's "Bounded exception -- weight-0decisiveness" accepts this one in writing (it does not reach the other), and Deployment carries both predicates, the operator-facing blast radius and a pre-upgrade audit query. Under every other algorithmweightsis not consulted and none of this applies. -
A negative weighted total fails the round. If the weights of the decisive voters sum below zero,
weightedfails the round outright rather than dividing by a negative denominator, which would invertratio >= thresholdand could report a pass on a reject. Registration refuses a negative entry invoting.weights(exclusiveMinimum: 0excludes negatives a fortiori), so this is reachable only from aPolicyDefinitionconstructed directly rather than registered. A total of exactly0.0is treated as no decisive result, not as a failure -- see the two "no decisive votes" bullets below. The operational consequences of the change, including the one commitment it moves from denied to allowed, are in Deployment. -
Inclusive comparison. Every threshold comparison is
ratio >= threshold, somajorityat its default0.5approves an even split. A rule where a tie fails isplurality, notmajorityat0.5. -
Ratios are binary64. Comparisons are Rust
f64. Withthreshold: 0.6666666666666666(the binary64 value nearest two-thirds, and what2.0 / 3.0produces) 2-of-3, 4-of-6, 20-of-30 and 67-of-100 pass while 66-of-100 does not. -
voting.quorumis inert on its own. It states the participation bar but gates nothing untilcommitment.require_vote_quorumistrue. A policy that setsvoting.quorumwithout it imposes no participation requirement. -
Vacuous participation floor (
schema_version >= 3). An effectively-zerovoting.quorum—quorumabsent,{"type": "count", "value": 0},{"type": "percentage", "value": 0}, or{"value": 0}with notype(all four are the same declaration; the last exercises the schema's own"count"default) — makescommitment.require_vote_quorumgate nothing:trueevaluates identically tofalse(RFC-MACP-0012 §4.1 "Vacuous participation floor",1.6.0-draft, spec #122). Registration does not refuse the combination and does not substitute a floor the policy never declared.policy.defaultitself ships exactly this shape ({"type": "count", "value": 0}), so the combination is the ordinary case, not an exotic one. The equivalence is scoped toschema_version >= 3— at 1 and 2require_vote_quorumkeeps the second, independent role the next two bullets describe, and a zero floor does not waive it. -
No decisive votes (
schema_version >= 3). With any algorithm other thannone, an empty decisive tally is binding on its own: a positive commitment is denied whatevercommitment.require_vote_quorumsays (RFC-MACP-0012 §4.1, "Empty tally"). The denial names the algorithm and the version. The runtime reports the result as no votes, never as a failed round, because §4.1 requires that so two conformant runtimes agree on what they observed -- and becauseunanimous's "every declared participant approved" predicate is vacuously true over an empty participant list, which §4.1 says must not be read as a pass. -
No decisive votes (
schema_version <= 2), the legacy rule. At versions 1 and 2 the algorithm instead produces no result on an empty tally, and whether that blocks a positive commitment is governed entirely bycommitment.require_vote_quorum-- so a version-1 or -2 policy that means its voting algorithm to be binding must set it. This arm is fail-open and is retained solely so that stored sessions replay identically (§4.1 "Retaining the legacy arm", §8); §4.1 also forbids applying it toschema_version >= 3. The selector is the policy's declared version, so both readings are live in the same binary at the same time. -
A vote-authorized decline on an empty tally is denied at every schema version, because such a decline needs at least one decisive explicit reject and an empty tally has none (RFC-MACP-0007 §6.2). The schema versions differ only in the positive direction. An objection-authorized decline is the exception -- see the next bullet.
-
Vote-authorized versus objection-authorized declines. A decline whose authorization comes from the tally is vote-authorized and is gated by the voting tri-state and the decline guard, as above. When the policy sets
objection_handling.critical_objection_action: "finalize_decline"and a standing critical objection blocks the positive direction, a decline is instead objection-authorized: its authorization is the recorded criticalObjection, so RFC-MACP-0007 §6.2 exempts it from both the tri-state and the decline guard -- the objection is the explicit, attributable dissent the guard exists to require. It is therefore available at every tally, the empty one included, and the allow reason names the veto rather than a vote result. Without this channel aschema_version >= 3session with a non-nonealgorithm, an empty tally and a standing critical objection could terminate only by expiry -- precisely the stuck statefinalize_declineexists to resolve. The waiver is whole:commitment.require_vote_quorum-- the decline guard's second conjunct -- goes with it, and so do theevaluation.required_before_votingandevaluation.minimum_confidenceprerequisites. The governing principle is authorization provenance: all three gate an outcome that derives its authority from the voting result, and an objection-authorized decline derives none, so §6.2 states that a runtime "MUST NOT deny it for an unmet voting quorum" and that the evaluation prerequisites "likewise MUST NOT be applied". Spec PR #126 settled this, closing the question this runtime raised as spec issue #117; earlier releases of this runtime kept both gates applying, which is the behaviour change described in Deployment. Two limits survive. The exemption is one-directional: a positive commitment underfinalize_declineis still denied by the veto -- with the unmet quorum, the unmet evaluation prerequisite and, atschema_version >= 3, the empty-tally denial all reported alongside it. And it is scoped to the action:deny(the default) andholdremain hard-stops in both directions. -
Resolved:
require_vote_quorumalongsidefinalize_declineno longer strands a session (spec #126, formerly this runtime's spec issue #117). §6.2 defines the decline guard as a two-conjunct conjunction whose second conjunct is the quorum condition -- "... and, whencommitment.require_vote_quorumistrue, the voting quorum MUST be met" -- and left it unstated whether waiving "the decline guard" reached that conjunct. Spec PR #126 ruled that it does, theevaluation.*prerequisites with it. Earlier releases of this runtime kept both gates applying, which stranded exactly one configuration:schema_version: 3,voting.algorithm: "majority",voting.quorum: {"type": "count", "value": 1},commitment.require_vote_quorum: true,objection_handling.critical_severity_vetoes: truewithcritical_objection_action: "finalize_decline", three participants, one standing critical objection and no ballot cast. The positive commitment was denied by the veto, the unmet quorum and the empty tally, and the decline was denied by the unmet quorum alone, so noCommitmentin either direction could be accepted -- precisely the stuck statefinalize_declineexists to remove. The decline is now allowed, and the pairing carries no caveat:commitment.require_vote_quorum: truewithcritical_objection_action: "finalize_decline"is safe to configure.evaluation.required_before_votingwith aminimum_confidenceno evaluation meets is safe for the same reason and by the same ruling. The conformance corpus pins the discriminator asdecision_finalize_decline_quorum_waiver.json. -
The decline guard counts only decisive rejects, and applies to all three voting results. RFC-MACP-0007 §6.2: a vote-authorized negative commitment must be backed by at least one decisive explicit
REJECT, and "the guard applies across all three voting results and at every policyschema_version". Two consequences. Underweighted, aREJECTfrom a participant outsideweightsdoes not satisfy it -- so a round with no listed dissenter cannot finalize a decline, whatever the unlisted voters cast. Andcommitment.allow_decline_over_approvalwaives the approval result, not the guard: on aPassedround the knob permits a decline only when a decisive reject also exists. An all-approve round, or one whose only rejects are non-decisive, is denied with a reason that names the missing decisive reject rather than the knob. This runtime previously applied the guard onFailedandNoVotesonly, so thePassedarm is a behaviour change and, unlike the rest of the guard, it needs noweightsmap to reach: an ordinarymajoritypolicy withallow_decline_over_approval: trueand an all-approve tally moves from allow to deny. It is therefore the second of the two rules that can change how an already-stored session replays -- see Deployment, predicate B. §8's bounded exception does not cover it, because §6.2 carried "applies across all three voting results" before spec #99: this is a pre-existing gap being closed, not new semantics.
Proposal Mode
{
"acceptance": { "criterion": "all_parties" },
"counter_proposal": { "max_rounds": 5 },
"rejection": { "terminal_on_any_reject": false },
"commitment": { "authority": "initiator_only" }
}Acceptance criteria: all_parties, counterparty, initiator.
Task Mode
{
"assignment": { "allow_reassignment_on_reject": true },
"completion": { "require_output": true },
"commitment": { "authority": "initiator_only" }
}Handoff Mode
{
"acceptance": { "implicit_accept_timeout_ms": 30000 },
"commitment": { "authority": "initiator_only" }
}Quorum Mode
{
"threshold": { "type": "percentage", "value": 66 },
"abstention": { "counts_toward_quorum": false, "interpretation": "neutral" },
"commitment": { "authority": "initiator_only" }
}The threshold field is spelled type, not threshold_type: the latter is the Rust field name, and a policy that uses it silently falls back to the default n_of_m.
Threshold types: n_of_m, percentage, and count — a documented alias for n_of_m that both the mode and the evaluator already treat as one. threshold.value must be a positive integer wherever the key is supplied, and at most 100 for percentage. The floor was inclusive until RFC-MACP-0012 1.2.0-draft (spec #110) moved it to exclusiveMinimum: 0, the quorum-side twin of the Decision tightening above: a zero approval bar is trivially satisfied, so a restrictive-looking quorum policy approved everything.
weighted is refused, and the canonical vocabulary now agrees. RFC-MACP-0012 1.2.0-draft (spec #110) removed it from threshold.type's enum and reserved the identifier: it was enum-legal but never had a weights map, an electorate rule, or a weighted analogue of RFC-MACP-0011 §5's count-only termination arithmetic, so no conformant evaluation of it ever existed. It must not be reused with a different meaning. This runtime had refused it as unimplemented since before the removal, so nothing changes here except the reason: weighted now fails the ordinary enum check rather than a dedicated arm.
count is the one place this runtime still departs from that enum, and the departure is now permanent rather than pending: #110 ruled the alias out of MACP vocabulary for good (spec issue #98 item 4), on the grounds that count names a participation floor in Decision Mode's voting.quorum while Quorum Mode's threshold is an approval bar, and that policy identity is byte-level rules equality (RFC-MACP-0012 §8), so an alias makes two semantically identical policies compare unequal forever. This runtime keeps accepting it because docs/policy.md has documented it and both layers already treat it as n_of_m; prefer n_of_m in new policies, and expect count to be withdrawn in a future release.
How the threshold resolves to an approval bar (RFC-MACP-0011 §5 rule 6 — a policy threshold replaces the ApprovalRequest's required_approvals, it does not supplement it):
n_of_m/count:valueapprovals.percentage: that share of the declared participants.- Fractional results are ceiled, and the bar has a floor of one approval. Before this release the mode truncated (
0.5→0) while the evaluator ceiled (0.5→1), so one policy meant two different bars; a bar of0was also reached before any ballot was cast, which let a negative commitment seal with zero approvals. Both layers now resolve through one function (QuorumThreshold::effective). percentageresolves with exact integer arithmetic:ceil(value × declared_participant_count / 100), equivalently(value × participants + 99) div 100. RFC-MACP-0012 §4.2 promoted the ceiling rule from a non-normative rationale into normative text and forbade floating-point division outright, and it was right to: this runtime used to divide by100first, which is inexact in binary64 for most integer percentages, and the error survived into the ceiling.value: 28over 25 participants asked for 8 approvals where the rule gives 7. Thirteen(value, participants)pairs diverged withinvalue1–100 and participants 1–100. The denominator is the count declared atSessionStartand does not shrink as ballots — abstentions included — are cast; see Deployment for who the correction reaches.- An omitted
value(or an omittedthreshold) leaves the rule inert: the ApprovalRequest's ownrequired_approvalsstands. A supplied0is refused at registration, soinertis now reached only by omission. - A bar outside
1..=participants— including an unrecognisedtype,weightedamong them, which resolves to "unsatisfiable" rather than to a raw count — makes the positive outcome impossible, so the ApprovalRequest is refused rather than opening a session that can only decline. Registration already refuses those types; this guard covers a policy edited under a running session. This refusal, and the ballot requirement in the next bullet, are both instances of the same deliberate deviation from RFC-MACP-0011 §5 rule 6 that Modes documents in full for thecounted > 0decline guard -- see that note for why the runtime declines to seal a ballotless outcome the RFC's own rule 6 would otherwise permit; not restated here. - A negative commitment needs at least one ballot. RFC-MACP-0011 §5 rule 4a makes an unreachable threshold the trigger for a decline, but with an empty ballot box "unreachable" only means the bar exceeds the participant pool, which is a misconfiguration rather than a decision.
Abstention interpretations: neutral, implicit_reject, ignored.
How evaluation works
Each standard mode has a dedicated evaluator in crates/macp-policy/src/evaluator.rs. Evaluation runs when a Commitment envelope arrives, after the mode's own validation has passed. It is a pure function of three inputs: the resolved policy rules, the accumulated accepted message history, and the session's declared participants. No wall-clock time, external calls, or out-of-session state are involved.
| Evaluator | What it checks |
|---|---|
evaluate_decision_commitment | Qualifying evaluations meet the confidence threshold, critical objection count stays below veto threshold, vote quorum is met, voting algorithm threshold is satisfied. REVIEW-type evaluations are excluded from confidence checks. |
evaluate_proposal_commitment | Counter-proposal count is within max_rounds |
evaluate_task_commitment | Output is present if require_output is set |
evaluate_handoff_commitment | Always allows (implicit timeout is handled by the mode) |
evaluate_quorum_commitment | Approval count meets the effective threshold for a positive commitment; a decline is not gated by it. Abstention interpretation is reported, not enforced |
Commitment authority
The commitment.authority rule determines who can send the terminal commitment, for the five standards-track modes only. It is checked by check_commitment_authority (crates/macp-modes/src/mode/util.rs), called from each of Decision's, Proposal's, Task's, Handoff's, and Quorum's Commitment handlers:
| Value | Who can commit |
|---|---|
initiator_only (default) | The session initiator |
any_participant | Any declared participant or the initiator |
designated_role | Only agents listed in the designated_roles array |
Extension modes never call it. ext.multi_round.v1 and any passthrough-backed registered extension hardcode initiator-only commitment authority and ignore commitment.authority entirely -- any_participant and designated_role have no effect on an extension-mode session, whatever the bound policy sets.
A breach of this rule is a sender-authorization failure, not a governance-rule failure: it returns FORBIDDEN, not POLICY_DENIED (RFC-MACP-0002 §6.1, RFC-MACP-0012 §10's POLICY_DENIED row states the exception explicitly). See the error table below.
Error handling
| Error code | When it occurs | gRPC status |
|---|---|---|
UNKNOWN_POLICY_VERSION | The policy_version in SessionStart is not found in the registry | FailedPrecondition |
POLICY_DENIED | A commitment is rejected because governance rules are not satisfied | FailedPrecondition |
FORBIDDEN | A commitment.authority / designated_roles breach -- the sender is authenticated but not the authorized commitment authority (RFC-MACP-0002 §6.1, RFC-MACP-0012 §10) | PermissionDenied |
INVALID_POLICY_DEFINITION | A policy fails one of the registration checks, or claims a reserved policy.std. identifier | InvalidArgument |
Two caveats on that status column, both visible in Self::status_from_error (src/server.rs):
- On the
Sendpath these codes travel in theAck-- the RPC itself succeeds, and the rejection surfaces asack.ok = falsewithack.error.codeset to the string above. The gRPC status only applies where an error escapes as aStatus.POLICY_DENIEDadditionally attaches its structured reasons asmacp-error-details-binmetadata. RegisterPolicy/UnregisterPolicyreport failures in band too (ok: falseplus anerrorstring, sinceRegisterPolicyResponsehas no code field), so reserved-namespace rejections carry the literalINVALID_POLICY_DEFINITIONat the head of that string.
When a commitment is denied, the error includes structured reasons explaining which rules were not met. They arrive in the order the checks run -- for Decision Mode: evaluation confidence, objection veto, vote quorum, then the voting result -- not in order of severity:
{
"reasons": [
"no qualifying evaluation meets minimum confidence threshold: 0.70",
"vote quorum not met: 1 voters of 3 participants (quorum: 60 percentage)"
]
}Default policy
The default policy (policy.default) is always registered with mode "*" and no governance constraints:
{
"voting": { "algorithm": "none", "quorum": { "type": "count", "value": 0 } },
"objection_handling": { "critical_severity_vetoes": false, "veto_threshold": 1 },
"evaluation": { "required_before_voting": false, "minimum_confidence": 0.0 },
"commitment": { "authority": "initiator_only", "designated_roles": [], "require_vote_quorum": false }
}Sessions with an empty policy_version automatically resolve to this default. It allows commitment whenever the mode's own built-in rules are satisfied.
Reserved policy.std. profiles
Every identifier beginning with policy.std. is reserved for the governance profiles published in RFC-MACP-0012 §5.2. The runtime enforces this in crates/macp-policy/src/registry.rs:
- A
policy_idunder the prefix is refused unless the descriptor is the canonical §5.2 definition for that identifier -- samemode, sameschema_version, and rules that resolve to the canonical values (a parameter left to its schema default counts as that default). The rejection carriesINVALID_POLICY_DEFINITION. - An identifier under the prefix that the RFC has not assigned --
policy.std.nonesuch, say -- is refused outright and does not resolve. ASessionStartnaming it is rejected withUNKNOWN_POLICY_VERSION. - A pre-registered
policy.std.profile cannot be unregistered, the same guardpolicy.defaulthas. - Both routes into the registry are covered: the
RegisterPolicyRPC and theMACP_POLICIES_DIRpreload, which funnels through the sameregisterpath. A policies directory containing apolicy.std.file aborts startup.
Short unnamespaced identifiers such as policy.majority are not reserved and remain available. Deployments should still use their own namespace (policy.{org}.{name}).
This runtime pre-registers all three profiles, so they appear in ListPolicies and resolve at SessionStart. Provisioning them is optional under §5.2 -- a runtime that ships none of them is still conformant -- but the reservation guarantees that an identifier which does resolve resolves to these rules everywhere. All three target macp.mode.decision.v1 at schema_version: 1, and all three set commitment.require_vote_quorum: true: under schema-version-1 semantics that flag is what makes the voting algorithm binding on an unvoted positive commitment, without which each profile would be vacuous in exactly the case it exists to govern. Under schema_version >= 3 the algorithm is binding on its own and the flag's remaining contribution is the voting.quorum participation floor (RFC-MACP-0012 §5.2). The profiles stay at schema_version: 1 regardless -- bumping them would redefine the canonical rules that §2.2 pins byte-identical on every runtime, and a deployment wanting the fail-closed reading registers its own schema_version: 3 policy instead. This runtime's behavior does not match §5.2's own worked claim of a schema_version divergence here, and that is RFC-MACP-0012's inconsistency, not a runtime deviation. §5.2 states the three profiles "differ only where an empty decisive tally nonetheless clears the participation floor... tallies that schema-version-1 semantics allow to support a positive commitment and that schema_version >= 3 denies." But the legacy empty-tally rule that actually governs schema_version 1 and 2 (§4.1) says a blocked positive commitment there "is... governed entirely by commitment.require_vote_quorum" -- a bare boolean, with no dependence on whether voting.quorum's value was actually met. This runtime implements §4.1 exactly that way (the NoVotes arm of evaluate_decision_commitment_outcome, crates/macp-policy/src/evaluator.rs), and that reading is pinned by the spec's own conformance corpus (tests/conformance/decision_legacy_require_vote_quorum.json denies an empty tally at schema_version: 2 with require_vote_quorum: true, independent of the configured quorum value). Since all three policy.std.* profiles set require_vote_quorum: true, the scenario §5.2 describes -- an abstention clearing quorum producing a different outcome at schema_version 1 than at 3 -- has no instance under this (§4.1-conformant, fixture-pinned) reading: all three profiles deny an empty decisive tally identically at schema_version 1 and 3, differing only in the deny reason. This was probed directly against the shipped evaluator by hand-tracing the NoVotes arm for each profile's quorum count against a lone abstention, confirming the result the conformance fixture already pins. Tracked upstream as an RFC-internal inconsistency at multiagentcoordinationprotocol#181; see DECISIONS.md for the full record. No runtime change is warranted.
| Policy ID | Governance bar |
|---|---|
policy.std.majority | At least half of the decisive votes approve (an even split approves) |
policy.std.supermajority | At least two-thirds of the decisive votes approve, with at least two voters |
policy.std.unanimous | Every declared participant has approved and no reject was cast |
{
"policy_id": "policy.std.majority",
"mode": "macp.mode.decision.v1",
"schema_version": 1,
"description": "Simple majority — at least half of the decisive votes approve",
"rules": {
"voting": {
"algorithm": "majority",
"threshold": 0.5,
"quorum": { "type": "count", "value": 1 }
},
"commitment": { "require_vote_quorum": true }
}
}{
"policy_id": "policy.std.supermajority",
"mode": "macp.mode.decision.v1",
"schema_version": 1,
"description": "Two-thirds supermajority with a minimum of two voters",
"rules": {
"voting": {
"algorithm": "supermajority",
"threshold": 0.6666666666666666,
"quorum": { "type": "count", "value": 2 }
},
"commitment": { "require_vote_quorum": true }
}
}{
"policy_id": "policy.std.unanimous",
"mode": "macp.mode.decision.v1",
"schema_version": 1,
"description": "Unanimous — every declared participant approves and no reject is cast",
"rules": {
"voting": {
"algorithm": "unanimous",
"quorum": { "type": "count", "value": 1 }
},
"commitment": { "require_vote_quorum": true }
}
}Note that policy.std.unanimous counts declared participants, not decisive votes: the session initiator is a declared participant under RFC-MACP-0007 §2, so it must vote too. A participant who abstains or never votes blocks the commitment.