MACP Policy Guide

Governance Policies provide a declarative, deterministic, and replay-safe mechanism for binding governance rules to Coordination Sessions. They specify how session outcomes are determined — voting algorithms, quorum thresholds, objection handling, commitment authority, and mode-specific constraints.

Status: Non-normative (explanatory). In case of conflict, the referenced RFC is authoritative. Reference: RFC-MACP-0012

Governance Policies provide a declarative, deterministic, and replay-safe mechanism for binding governance rules to Coordination Sessions. They specify how session outcomes are determined — voting algorithms, quorum thresholds, objection handling, commitment authority, and mode-specific constraints.

Why Policies Exist

Modes (RFC-MACP-0002) define coordination semantics but intentionally do not prescribe governance algorithms. Decision Mode, for example, supports majority vote, weighted vote, veto rules, and other deterministic policies. The policy framework fills that gap: policies are authored via SDKs, registered with the runtime, resolved at SessionStart, and evaluated at commitment time.

Policy Identifiers

Policy identifiers use the form policy.{namespace}.{name}, per RFC-MACP-0012 Section 2:

  • policy.default — the built-in default policy (reserved)
  • policy.fraud.majority-veto — a domain-specific policy
  • policy.lending.unanimous — another domain-specific policy

The policy.default identifier is reserved and always pre-registered. The whole policy.std. namespace is also reserved — as a collision guarantee, not a provisioning requirement: a runtime MAY pre-register any subset of the built-in profiles (or none), but if it does, the rules MUST match the canonical definition exactly; short unnamespaced forms such as policy.majority are explicitly not reserved. See RFC-MACP-0012 Section 2.2 for the full reservation rules, RFC-MACP-0012 Section 5.2 for the canonical definitions of the three reserved policy.std.* profiles (majority, supermajority, unanimous), and registries/policies.md for the authoritative reserved-identifier table, which also lists the per-mode rule-schema set this document's own table (below) covers with a distinct Key Parameters column. Registered policy identifiers are immutable — to change governance rules, register a new policy with a new identifier. This ensures that policy_version in historical sessions always resolves to the same rules.

Policy Descriptor

A policy descriptor has five required fields:

FieldTypeDescription
policy_idstringUnique policy identifier
modestringTarget mode identifier or * for mode-agnostic
descriptionstringHuman-readable description
rulesobjectMode-specific governance rules (see Rule Schemas)
schema_versionuint32Version of the rule schema used (1, 2, or 3). Version 2 adds Decision Mode decline-gating and is additive. Version 3 is the first semantic bump: it changes how an empty vote tally is evaluated (see Empty tallies below). A stored policy is always evaluated under the version it declares, so 1 and 2 policies keep their original behavior forever. The set is enforced, not merely documented: macp-policy-descriptor.schema.json carries it as an enum, so adding a version means editing that enum and CI fails until it agrees with lint_fixtures.py.

Canonical proto: schemas/proto/macp/v1/policy.proto JSON Schema: schemas/json/macp-policy-descriptor.schema.json

In the Protobuf wire format, rules is a string containing JSON-encoded text. In JSON examples, rules is shown as a decoded JSON object for readability.

Rule Schemas by Mode

Each standard mode defines a normative JSON Schema for its governance rules:

ModeRule SchemaKey Parameters
Decisiondecision-rules.schema.jsonVoting algorithm, threshold, weights, quorum, objection handling, evaluation constraints, commitment authority, designated_roles
Quorumquorum-rules.schema.jsonThreshold override, abstention handling, commitment authority, designated_roles
Proposalproposal-rules.schema.jsonAcceptance criterion, max negotiation rounds, rejection behavior, commitment authority, designated_roles
Tasktask-rules.schema.jsonReassignment on reject, output requirement, commitment authority, designated_roles
Handoffhandoff-rules.schema.jsonImplicit accept timeout, commitment authority, designated_roles

commitment.authority is shared vocabulary across all five standard modes, over the same three values: initiator_only, any_participant, designated_role. A policy selecting designated_role MUST also supply commitment.designated_roles naming at least one role, and a runtime MUST reject such a descriptor at admission when the list is missing or empty — stated once for all five modes, rather than repeated per mode, in RFC-MACP-0012 Section 4, and enforced structurally by each rule schema above.

Rule objects are closed. Every object level in every rule schema above sets additionalProperties: false, so a key the schema does not define is rejected when the policy is registered rather than quietly ignored. See RFC-MACP-0012 Section 4 for the normative closure rule, the ^[_$] annotation namespace, and the voting.weights exception below. This matters more than it sounds: a misspelled parameter used to validate clean and leave the real parameter at its default, so a session ran under a policy nobody wrote and nothing reported an error — objection_handling.veto_threshhold: 3 left veto_threshold at 1, so one blocking objection vetoed a commitment whose author required three.

Keys beginning with _ or $ are a reserved annotation namespace. They are legal at every nesting level, carry no governance semantics, and evaluators must ignore them, so documentation and tooling metadata can travel with a policy without widening its governance surface. Two rules objects that differ only in annotation keys are equivalent everywhere, including for replay.

One deliberate exception: Decision Mode's voting.weights is not closed. Its keys are participant identifiers, and its additionalProperties types the map's values rather than closing the object. It is constrained instead by minProperties: 1 and a per-value floor.

Decision Mode supports six voting algorithms: none, majority, supermajority, unanimous, weighted, and plurality. See RFC-MACP-0012 Section 4.1 for full details.

Quorum Mode expresses its bar through threshold, which takes one of two types: n_of_m (a raw approval count) and percentage (an integer 1–100 of the participant count declared at SessionStart). A percentage threshold resolves to an effective approval count that is rounded up — ceil(value × declared_participant_count / 100) — so a fractional product never lowers the bar: 50 over 3 participants requires 2 approvals, not 1. threshold.value must be greater than 0 in either type. See RFC-MACP-0012 Section 4.2.

Two identifiers are not part of that vocabulary. weighted was removed in RFC-MACP-0012 1.2.0-draft — it was enum-legal but never had a weights vocabulary, 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; its identifier is reserved and must not be reused with a different meaning. count has never been MACP vocabulary: some implementations accepted it as an alias for n_of_m, but count names a participation floor in Decision Mode's voting.quorum, and reusing it for Quorum Mode's approval bar would make one token mean two different gates in adjacent modes.

Decision Mode's threshold must be greater than 0, at least 0.5 for majority, and greater than 0.5 for supermajority — and under supermajority it is required, not defaulted: the field's documented 0.5 default is a value that algorithm's own constraint forbids, so omitting it would otherwise yield a supermajority that is a bare majority wearing the name. The > 0 floor is enforced unconditionally, so unanimous and plurality — which never consult threshold — still reject an explicit 0. Threshold comparisons are inclusive (ratio >= threshold), so majority at the default 0.5 approves an even split. The denominator is the decisive votes — those cast as approve or reject; abstentions are excluded.

The weighted electorate

Under weighted, the weights map is the electorate. A declared participant absent from the map has weight 0, which is how an observer is expressed — the schema rejects an explicit 0 and an empty map. A weight-0 vote is accepted as a message and preserved in history, but it is non-decisive: it contributes to neither side of the ratio, does not enter the decisive tally, and does not authorize a decline. It still counts as a vote cast for the voting.quorum participation floor.

This rule is normative at every schema_version, not just 3. So is the decline guard: a vote-authorized negative commitment must be backed by at least one decisive explicit REJECT vote, which means a REJECT from a weight-0 participant never authorizes one.

Empty tallies and schema_version

What happens when a commitment is attempted before any decisive vote has been cast depends on the schema_version the bound policy declares. This is the one place the versions differ in behavior rather than in vocabulary.

schema_version 1 and 2schema_version 3
Positive commitment on an empty tallyThe algorithm produces no result — it neither passes nor fails. Whether the commitment is blocked is governed solely by commitment.require_vote_quorum. With it false (the default), the commitment is allowed, even under majority or unanimous.Denied for every algorithm except none. The algorithm is binding on its own; its predicate is evaluated over the actual tally, including the empty one, and fails.
Vote-authorized negative commitment on an empty tallyDenied for every algorithm except none — the decline guard needs a decisive REJECT and there is none.Denied for every algorithm except none, for the same reason.
voting.algorithm: "none"Unaffected.Unaffected.

For weighted, "empty tally" means zero total decisive weight, which covers both no ballots at all and a complete ballot set cast entirely by weight-0 participants.

The table governs vote-authorized commitment only. There is one other way a session can resolve negatively on an empty tally, at every schema_version from 2 onward: if the policy sets objection_handling.critical_objection_action to finalize_decline and a critical Objection is standing, the decline is objection-authorized — the objection is itself the attributable dissent the decline guard exists to require, so neither the guard nor the empty-tally rule applies. critical_objection_action is a three-value enum (deny | finalize_decline | hold, default deny): deny and hold are observationally identical — both reject the Commitment with POLICY_DENIED and leave the session OPEN, and a runtime MUST NOT expose a wire-visible distinction between them. hold is purely an operator-facing annotation on the denial, not a distinct protocol outcome, which is why the conformance corpus pins deny and finalize_decline only — a hold fixture would assert nothing a deny fixture does not already assert. See RFC-MACP-0012 Section 4.1. The guard is waived whole: its commitment.require_vote_quorum conjunct goes with it, as do the evaluation.* prerequisites, because all three gate outcomes that derive their authority from the voting result and this decline derives none. A runtime that keeps the quorum applying here reconstructs the stuck state finalize_decline exists to remove — no commitment acceptable in either direction, and no committed outcome reachable at all. Without that channel a session with a bound algorithm, no votes, and a standing critical objection could reach no committed outcome at all — it would end only by cancellation or expiry, neither of which records one. ("Only by expiring" overstates it: the initiator can always submit the CancelSession RPC — the SessionCancel envelope is emitted by the runtime, not the initiator — and a single ABSTAIN clears a count-1 participation floor. What is unreachable is a committed outcome, not termination.)

The 1/2 behavior is fail-open: a policy that looks restrictive approves when nobody votes, and adding one approving ballot could convert an allowed commitment into a denied one. It is retained solely so that stored sessions replay identically — policy equality is policy_id + schema_version + rules, and a runtime MUST evaluate a stored policy under the version it declares even when a newer one exists. Implementations MUST keep this arm and MUST NOT apply it to schema_version 3 or later policies.

If you are writing a new policy, declare schema_version: 3. If you must stay on 1 or 2 and want the voting algorithm to be binding, set commitment.require_vote_quorum to true — that is the only remedy available before version 3.

Two companion rules govern commitment.require_vote_quorum and voting.quorum themselves, both normative at RFC-MACP-0012 Section 4.1. First, voting.quorum is inert on its own — it states a participation bar but gates nothing unless commitment.require_vote_quorum is true; a policy that sets one without the other imposes no participation requirement. Second, under schema_version ≥ 3 the only effect require_vote_quorum still has is that same participation floor (the algorithm is already binding on its own at that version). So when the floor is effectively zero — voting.quorum absent, or an explicit value: 0 under either count or percentage — require_vote_quorum: true becomes equivalent to false: the flag gates nothing. This is an authoring smell, not an admission error — a runtime MUST NOT reject the descriptor and MUST NOT substitute a floor the policy did not declare.

Default Policy

Every conformant runtime MUST pre-register the default policy:

{
  "policy_id": "policy.default",
  "mode": "*",
  "schema_version": 1,
  "description": "Default policy — mode built-in rules apply with no additional governance constraints",
  "rules": {}
}

When policy_version in SessionStartPayload is empty or equals policy.default, the runtime applies this default. It adds no governance restrictions on top of mode validation.

Policy Evaluation

Resolution

At SessionStart, the runtime resolves policy_version from the payload. If empty, it resolves to policy.default. If the policy is not found, the runtime rejects with UNKNOWN_POLICY_VERSION. The resolved PolicyDescriptor is stored on the session for its lifetime.

Commitment Evaluation

When a Commitment envelope arrives, the runtime evaluates the policy's rules against accumulated session state. If satisfied, the Commitment is accepted. If not, the runtime rejects with POLICY_DENIED.

Policy evaluation layers on top of mode validation: mode validation runs first, then policy rules adjust eligible behaviors within mode boundaries. A Commitment must satisfy both to be accepted.

Determinism

Policy evaluation MUST be a pure function of the resolved rules, the accumulated accepted message history, and the session's declared participants. It MUST NOT depend on wall-clock time, external calls, randomness, or state outside the session boundary. See RFC-MACP-0012 Section 6.3.

Registration Lifecycle

Policies are managed through five gRPC RPCs on MACPRuntimeService:

RPCPurpose
RegisterPolicyRegister a new policy descriptor
UnregisterPolicyRemove a registered policy (does not affect active sessions)
GetPolicyRetrieve a policy descriptor by ID
ListPoliciesList registered policies, optionally filtered by mode
WatchPoliciesStream policy registry change notifications

Registration constraints, summarized — see RFC-MACP-0012 Section 7 for the full normative list: policy.default cannot be registered or unregistered, and a policy_id under the reserved policy.std. namespace cannot be registered unless it is the canonical definition for that identifier, nor unregistered once pre-registered; policy_id must otherwise be unique. Rule-schema validation against rules is an admission-time gate only — it runs when a descriptor enters the runtime and never again, so a later tightening of a rule schema bars new admissions without retroactively invalidating a descriptor already stored.

Canonical proto definitions: schemas/proto/macp/v1/policy.proto

Replay Invariant

The resolved PolicyDescriptor MUST be persisted as part of the session snapshot. During replay, the runtime MUST use the stored descriptor — never re-resolving from the registry. Policy equality uses policy_id + schema_version + rules, not full descriptor byte comparison. See RFC-MACP-0003 and RFC-MACP-0012 Section 8.

Examples

What CI Validates

make json-validate checks every policy rules object in the repository against its mode's rule schema in schemas/json/policy/. The mode is taken from the mode field beside the rules object; mode: "*" (the mode-agnostic default policy) is skipped, and a mode naming no known rule schema is a hard failure rather than a silent skip.

This reaches rules objects wherever they sit — discovery descriptors, conformance fixtures, the nested descriptor in examples/policy-registration-exchange.json, and the fenced JSON blocks in the RFCs and in this document. The total instance count across the repository is pinned by EXPECTED_RULES_INSTANCES in scripts/validate-json.sh, so adding or removing any mode+rules JSON block anywhere in rfcs/ or docs/ — this document's own Default Policy block above included, even though its mode: "*" is skipped rather than validated — turns make json-validate red until that variable is updated.

Every object level in every rule schema sets additionalProperties: false, per RFC-MACP-0012 Section 4 and "Rule objects are closed" above, so an unrecognized rule field is rejected at admission, not accepted and ignored. What it still does not check: none of the five rule schemas sets a top-level required, so an empty rules object {} validates against all five — a green run means nothing on disk contradicts its schema, not that the schemas are complete or that every mode enforces a non-empty rule set.

Beside the negative corpus below, schemas/json/tests/valid-policy-rules/ holds one maximal fixture per mode, asserted to VALIDATE. Maximality is derived from the schema itself: the suite walks every subschema declaring its own properties and requires the fixture to exercise all of them, so adding an object-valued property to a rule schema turns the run red until the fixture grows. Each fixture also carries a top-level $comment and a nested _note (proving the annotation namespace survives closure) and sets commitment.authority: "designated_role" with a non-empty designated_roles (see schemas/json/tests/valid-policy-rules/README.md).

Policies for extension modes (ext.* and reverse-domain identifiers) are logged and skipped: they have no standards-track rule schema by design. Only an unrecognized macp.mode.* identifier is an error.

Every mode's rule schema also has a negative corpus under schemas/json/tests/ — one directory per mode, each fixture required to FAIL its schema and each isolating exactly one constraint, so that removing that constraint flips exactly one fixture. Without them the constraints would be unenforced in the direction that matters: a schema only proves it rejects what it should if something on disk is actually rejected by it.

What CI Checks About the RFCs

make prose-check (scripts/check-prose.py) checks RFC prose against the artifacts that implement it. make validate never did: it verifies that JSON matches schemas and that protos compile, but not that a normative sentence is true, and not that two RFCs agree.

Eight checks, chosen because they are mechanical:

  • No line-number anchors. A citation that pins a source line — an open paren, a colon, a line number, a close paren — drifts the moment anything above it is edited. Cite the heading instead. (The check is self-applying: an earlier draft of this very paragraph used a literal example and was rejected by it.)
  • schema_version enumerations agree. The valid set is spelled out in seven places across markdown, .proto comments, and JSON Schema. Six of the seven are prose and are matched by regex; the seventh is structural — the policy descriptor schema's own enum, read as JSON, which is the artifact that enforces the set the other six merely describe. That file is therefore checked twice, on purpose: once for what it says and once for what it does. lint_fixtures.py is the source of truth all seven are compared against, and is not itself one of them.
  • RFC cross-references resolve. A §N.M pointing at a section that does not exist is detectable. A bare section number resolves against its own document first and only then against the nearest preceding RFC citation, and references to non-MACP standards (IETF RFCs) are left alone.
  • Cited terms appear in the RFC that is cited. A sentence that attributes a claim to another RFC, where that RFC never mentions the term, is mechanically detectable. This is the shape of the abstention citation that pointed at an RFC containing the word zero times.
  • The README version census matches. README.md hand-maintains a per-RFC version roll-call that nothing verified; it went stale twice during this work alone.
  • The check count agrees with the prose describing it. This document, README.md and check-prose.py's own docstring each state how many checks run, and nothing held them in step — README.md said four while this file said five and five ran. The canonical number is read from main()'s abstract syntax tree, not from a regex over the source, for the same reason the schema_version check reads JSON: a regex would match the check names in docstrings and comments too. The check counts itself. This document's bullet list and the docstring's numbered list are both held to the count as well; README.md's inline clause is not, because it has no per-item marker to anchor on. Keeping that one as long as the number says remains a human job, and the check says so in its own failure message rather than only in a docstring.
  • The PolicyDescriptor required-field set agrees. Four sites are checked against the schema's required array, which is what actually enforces the set. Three name the fields — RFC-MACP-0012 §3's table, the table above, and lint_fixtures.py's own tuple — and the fourth, the sentence introducing the table above, states only how many. Nothing compared them, and they drifted: the RFC listed five while the schema required four, so a descriptor with no description validated clean. The schema is read as JSON and the linter's tuple as Python source by AST; the two markdown tables and the count sentence are matched by pattern.
  • The parity-contract "held to other manifest values" enumeration agrees. A few schemas/parity/contract.json values have no in-repo source for one specific comparison — check-parity-contract.py instead holds them to ANOTHER value inside the same manifest (retry.backoff_schedule_seconds recomputed from the other retry fields; retry.retryable_error_codes checked against the manifest's own error_codes.permanent; contribute_payload.first_byte's two discriminator bytes checked against the manifest's own vectors; commitment_hash.accept/reject checked against the manifest's own commitment_hash.pattern). schemas/parity/README.md and this repo's docs/sdk-parity.md each hand-maintain a prose list of exactly that set, and nothing held them to each other — both were stale in different ways (issue #157).

What it still does not check: whether a paragraph is true. Nothing here would have caught an RFC asserting a constraint its schema does not impose, beyond the eight narrow classes above. That remains a human job.

A sibling checker, scripts/check-envelope-coverage.py (make envelope-coverage, issue #173), covers an axis this document's eight checks do not: whether every Core payload message and field declared in schemas/proto/macp/v1/core.proto has a corresponding entry in macp-envelope.schema.json's $defs. It is a proto↔JSON-Schema coverage check, not a prose-vs-artifact check, so it is not one of the eight above and is mutation-tested separately (make envelope-coverage-selftest) rather than folded into this count. check-prose.py itself has the same kind of regression proof — make prose-check-selftest (scripts/check-prose-test.py, issues #128/#129) asserts it survives an unreadable file rather than crashing the whole suite — and both self-tests are their own targets among the 14 that make validate runs.

Error Codes

CodeDescriptionReference
UNKNOWN_POLICY_VERSIONPolicy not found in registry at SessionStartRFC-MACP-0012 Section 10
POLICY_DENIEDCommitment rejected by governance policy rules (except a commitment.authority/designated_roles breach, which is a sender-authorization failure and uses FORBIDDEN instead, per RFC-MACP-0002 §6.1)RFC-MACP-0012 Section 10
INVALID_POLICY_DEFINITIONPolicy descriptor fails validationRFC-MACP-0012 Section 10

Full error code registry: registries/error-codes.md