Projections API Reference

Projections are pure state machines that track session state client-side. Each coordination mode has its own projection class.

Common Interface

All projections share:

PropertyTypeDescription
transcriptEnvelope[]The session's accepted history as a conforming runtime holds it: one envelope per unique message_id, in application order. See Redelivery below.
phasestring literal unionCurrent session phase
commitmentRecord<string, unknown> | undefinedCommitment payload, set on Commitment
isCommittedboolean (getter)true once a Commitment has been applied
isPositiveOutcomeboolean | undefined (getter)Commitment's outcomePositive; undefined before commit, true when the field is absent
anomaliesProjectionAnomaly[]Cardinality anomalies recorded while replaying the accepted transcript. See Anomalies below.
hasAnomaliesboolean (getter)true once at least one anomaly has been recorded

All projections implement:

applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): void

This method:

  1. Checks the envelope's mode matches (ignores others)
  2. Appends to transcript
  3. Decodes the payload via protoRegistry.decodeKnownPayload()
  4. Updates internal state based on messageType

Input contract

applyEnvelope assumes every envelope passed to it was accepted by a conforming MACP runtime — i.e. it is part of the session's authoritative accepted history, never a submission the runtime rejected. This is a caller-maintained invariant, not something applyEnvelope itself can verify: Envelope carries no acceptance marker on the wire, so there is no field a projection could inspect to tell an accepted envelope from a rejected one.

The rule is canonical upstream, in the spec repo's schemas/conformance/README.md "Notes:" section, verbatim: "SDKs replay only accept messages through their projections (reject-path fixtures replay their accepted prefix)." It backs a substantive protocol guarantee — RFC-MACP-0007 (Decision Mode) §5 rule 3 ("the first accepted Vote stands") and RFC-MACP-0011 (Quorum Mode) §5 rule 3 (at most one ballot per participant) both describe what a conforming runtime enforces on accepted history — so a projection reconstructing that history is only correct when it is fed the same set the runtime held.

This SDK's own send paths already uphold the invariant: each of the five built-in mode sessions has its own private sendAndTrack that calls this.projection.applyEnvelope(...) only after a successful ACK (gated on ack.ok). BaseSession's own sendAndTrack — the equivalent path for custom modes built on the ext-mode extension point — does the same. The conformance test harness upholds it too, by filtering fixture messages to expect === 'accept' before replaying them.

Failure mode if the contract is violated: feeding applyEnvelope raw captured or hand-built traffic — including envelopes a runtime rejected — replays a timeline no conforming runtime ever held, and the projection fabricates state accordingly. For example, a session whose only Commitment was rejected can still surface isCommitted === true and phase === 'Committed' if that rejected envelope is replayed anyway, making an unresolved session look resolved. See tests/unit/projections/accepted-only-contract.test.ts for an executable demonstration, across DecisionProjection, QuorumProjection, and a custom BaseProjection subclass.

If you are building your own transport or replay path, filter to accepted envelopes yourself before calling applyEnvelope — do not rely on applyEnvelope to reject anything on your behalf.

There is also an implicit one projection per session contract: the message_id dedup key described below is global to the projection instance, not scoped to (sessionId, message_id) — applyEnvelope never inspects envelope.sessionId. Feeding the transcripts of two different sessions into one projection instance can therefore silently drop envelopes whose message_ids happen to collide across sessions. This mirrors the runtime's own dedup set, which is likewise kept per session, so the fix is to give each session its own projection instance, not to widen the dedup key.

Redelivery (message_id dedup)

applyEnvelope is idempotent with respect to message_id: applying the same envelope (identical message_id) more than once has no effect after the first application — it is not appended to transcript, and it is not passed to the mode-specific switch/applyMode. This is required by RFC-MACP-0006 (Transport Bindings) §3.2 Redelivery:

  • A runtime MAY echo back accepted client-submitted envelopes on the stream as part of the authoritative accepted sequence (:94) — this is why a redelivered envelope is expected traffic, not a defect to route around.
  • A redelivery MUST NOT advance the client's sequence position (:134) or count a second time against any Mode cardinality rule — "a second" means a distinct message_id, never the same envelope arriving twice (:135).
  • A consumer that accumulates state per envelope — appending to a list, incrementing a counter — MUST be idempotent with respect to message_id (:136).

This closes a live gap on the Participant happy path: the shared projection instance below means every initiator envelope is naturally applied twice — once locally on ACK via the mode session's own sendAndTrack, and again when the transport replays accepted history. Before this dedup, that double-apply silently corrupted every accumulate-on-apply site (Decision evaluations/objections, Proposal accepts/rejections, Task updates/completions/failures).

Key properties:

  • Empty/absent message_id is never deduped. The guard is gated on if (envelope.messageId); an id-less envelope always applies. This matters for hand-built envelopes from callers who don't set messageId — treating every one of them as "the same message" would collapse a whole feed into one entry.
  • A redelivery is not an anomaly. MACP's transport is at-least-once by design (RFC-MACP-0001 §8), so a redelivery is logged at debug, never warn — there is no anomaly-tracking surface on projections today that a redelivery could be mistakenly reported through.
  • The dedup set is unbounded, deliberately. The projection already retains every full envelope (payload bytes included) in transcript, so a Set<string> of ids is strictly dominated by that; sessions are also TTL-bounded by protocol.
  • GrpcTransportAdapter is covered end to end (it is the path the shared projection instance below is about). HttpTransportAdapter's array (Python-style polling) branch normalizes message_id → messageId before yielding, specifically so this guard can see a real id for polled envelopes too.

What changed

Before this dedup, applyEnvelope appended every envelope that reached it (past the mode check) unconditionally — a second delivery of the same message_id was pushed onto transcript again, and passed to applyMode again. If you were relying on transcript as an exact receipt of everything you handed to applyEnvelope, that reading was already unreliable before this change, for two independent reasons:

  • applyEnvelope has always silently dropped envelopes for a different mode (the if (envelope.mode !== this.mode) return; guard predates this change) — so "an exact receipt of everything handed in" was never the contract, dedup or not.
  • On this SDK's own Participant happy path, the shared projection instance below means the initiator's own envelope is naturally applied twice — once locally on ACK via the mode session's sendAndTrack, once again via replayed transport history — so a consumer treating transcript as a raw receipt was already getting corrupted data (each such envelope duplicated) before this fix landed, not after it.

If you genuinely need a raw receipt of every envelope you passed to applyEnvelope, keep your own list of what you passed in — you already hold those envelopes at the call site. transcript is deliberately not that list, before or after this change; it is the runtime's accepted, deduplicated history.

Rollback on failed decode

applyEnvelope adds the envelope's message_id to the dedup set described above and appends it to transcript before decoding the payload — decode is the method's only fallible step. If decode throws (a malformed payload — in practice this should only happen against a non-conforming source, since a conforming runtime never emits one), both of those mutations are rolled back and the error is re-thrown, so the envelope is left exactly as if applyEnvelope had never been called for it.

This matters because of how the redelivery dedup guard works: without the rollback, a failed decode would still leave the message_id marked "seen," so a legitimate retry of that same envelope — even with a corrected payload — would be silently absorbed as a redelivery (see Redelivery above) and its effect would be lost permanently, while transcript claimed a partial, never-decoded entry was present.

Deliberately narrow scope. Only transcript and the message_id dedup set are guaranteed rolled back. Mode/subclass-owned state (phase, votes, tasks, and any state a custom BaseProjection subclass's own applyMode mutates) is not rolled back. This is safe only because every projection performs its one fallible operation (the decode) strictly before any state mutation, and constructing a record from an already-decoded payload cannot itself throw — so in practice nothing is ever left half-mutated. A BaseProjection subclass whose own applyMode mutates state and then throws is the one case where this boundary is visible: that mutation is not undone, only transcript/the dedup set are. Tests: tests/unit/projections/rollback-invariant.test.ts (all six entry points, including that narrow-scope boundary pinned explicitly).

Parity note: mirrors macp-sdk-python's base_projection.py apply_envelope, which has always rolled back the same way.

Anomalies

ProjectionAnomalyKind and ProjectionAnomaly — exported from the package root, together with the anomalies field and hasAnomalies getter on every projection — record cardinality anomalies observed while replaying an accepted transcript. Like transcript, anomalies is readonly at the field level only — that keeps a consumer from rebinding the property to a different array, it does not make the array itself immutable; .push() onto anomalies works exactly like .push() onto transcript.

export type ProjectionAnomalyKind =
  | 'duplicate_vote'
  | 'duplicate_ballot'
  | 'duplicate_task_accept'
  | 'settled_handoff';

export interface ProjectionAnomaly {
  kind: ProjectionAnomalyKind;
  mode: string;
  messageType: string;
  messageId: string;
  sender: string;
  /** proposal_id (Decision), request_id (Quorum), task_id (Task), or handoff_id (Handoff) the anomaly targeted */
  subjectId: string;
  detail: string;
}

This is a cross-SDK frozen contract, agreed with macp-sdk-python (same seven fields, snake_case there) — do not add, rename, or remove a field without cross-SDK agreement. duplicate_task_accept/settled_handoff were added under exactly that agreement (issue #126/#128 — macp-sdk-python landed its half in PR #95, this SDK's PR #134 completed it). Four related exports pin this contract further: ANOMALY_DUPLICATE_VOTE/ ANOMALY_DUPLICATE_BALLOT/ANOMALY_DUPLICATE_TASK_ACCEPT/ ANOMALY_SETTLED_HANDOFF are the four ProjectionAnomalyKind string values as named constants, and PROJECTION_ANOMALY_FIELD_ORDER is the field list above as a runtime readonly tuple, in order. All four kinds are now asserted against the spec repo's schemas/parity/contract.json manifest (projection_anomaly.kinds/.fields, contract_version 1.2.0) by this SDK's own test suite; see Testing § Parity Contract Gate.

What an anomaly means — deliberately narrow, agreed wording across both SDKs: an anomaly records that one of the following was observed and discarded:

  • A second distinct Vote from this sender for this proposal (duplicate_vote, RFC-MACP-0007 §5.3) — the first stands.
  • A second distinct ballot across Approve/Reject/Abstain from this sender for this request (duplicate_ballot, RFC-MACP-0011 §5 rule 3) — the first stands. (RFC-MACP-0011 §5 rule 3 states "the first accepted ballot stands" directly, since the rule-3 hardening in spec PR multiagentcoordinationprotocol#85; first-ballot-wins here was parity with RFC-MACP-0007 §5.3 plus runtime-enforced behaviour before that hardening.)
  • A TaskAccept for a task this projection has a TaskRequest on file for, arriving after some task already holds the session's one assignee slot (duplicate_task_accept, RFC-MACP-0009 §5 rules 3/3a) — the slot holder is unchanged; subjectId names the losing task_id, not the holder's.
  • A HandoffAccept/HandoffDecline for a handoff_id that already settled as accepted or declined (settled_handoff, RFC-MACP-0010 §5 rule 4) — the prior settlement is unchanged.

It does not, and structurally cannot, claim "this transcript violates the spec": a projection has no way to tell a genuinely non-conforming source from a conforming source fed through an unfiltered loader, because acceptance is not a wire property (see Input contract above). Do not read more into an anomaly than what was observed and what this projection did about it.

A TaskAccept/HandoffAccept/HandoffDecline for an unknown task_id/ handoff_id records no anomaly. This is a deliberate, investigated determination (issue #126/#128), not an oversight: an unknown id is not caller misuse — a projection that joined mid-session may never have observed the original TaskRequest/HandoffOffer.

DecisionProjection, QuorumProjection, TaskProjection, and HandoffProjection populate anomalies today. ProposalProjection exposes the same field and getter for a uniform surface, but nothing currently writes to it.

Recording an anomaly does two things:

  1. Pushes the ProjectionAnomaly onto anomalies — the canonical, cross-SDK agreed semantic. Read this array; it is what you should build logic on.
  2. Emits logger.warn('projection anomaly', anomaly) — this half is explicitly non-contractual observability and may differ per SDK. logger.warn is visible by default (this SDK's default log level is warn, see src/logging.ts): observing and discarding a duplicate is deliberately not silent. If you need to quiet the log line without losing the anomalies array, raise the log level — configureLogging({ level: 'error' }) or the MACP_LOG_LEVEL environment variable.

BaseProjection also exposes a protected recordAnomaly(anomaly) helper that does both of the above; DecisionProjection, QuorumProjection, TaskProjection, and HandoffProjection (see BaseProjection (custom modes) below) call it from their respective anomaly-detection call sites.

Design intent: shared projection instance

Participant and the mode session it wraps deliberately share one projection instance, not two. The session's own sendAndTrack applies an envelope to that instance locally as soon as its own send() call is ACKed; the same instance is later handed replayed or live envelopes again via Participant.processMessage as the transport streams (or re-streams, on reconnect) session history. Both paths write into the same object by design — this is deliberate topology, not an oversight being disclosed here.

One consequence of that choice: it is what makes a double-apply of the initiator's own envelope reachable on this SDK's ordinary Participant happy path — the local apply-on-ACK and the later replay-apply can both land on the same instance for the same envelope. This is recorded here as a statement of intent so the topology choice is visible and deliberate, not implicit.

For comparison: macp-sdk-python gives the local apply-on-ACK path and the stream-replay path two separate projection instances that never meet, so this particular bug class cannot occur there by construction. Concretely, Python's Participant never constructs a session-backed projection at all — its session-driven and stream-driven projections are separate objects on separate paths that never meet, whereas this SDK's Participant and its mode session deliberately share the one instance described above. Neither SDK has published a position on which topology is "correct" — this section states this SDK's own design intent without asserting that Python's is wrong. The point of stating both sides explicitly (Python documents the reciprocal statement on its side) is that this asymmetry is a known, load-bearing difference between the two SDKs, not something either could quietly refactor away without noticing.

BaseProjection (custom modes)

BaseProjection is the abstract base for all six projections in this SDK, including custom (extension) modes — pair a custom subclass with BaseSession. It handles Commitment (sets commitment, moves phase to 'Committed'), the transcript, message_id redelivery dedup, and the rollback-on-failed-decode invariant for free; subclasses supply the mode string and override applyMode(envelope, protoRegistry) for the mode-specific message types. The five built-in projections below all extend BaseProjection this way (issue #91 — this was previously five independent copies of the same dedup/transcript/rollback logic; see src/projections/base.ts's applyEnvelope for the shared implementation).

Every phase write — in BaseProjection itself and in all five built-in subclasses — now goes through a shared, protected setPhase() choke point that no-ops once phase === 'Committed' (issue #150; ports macp-sdk-python's _set_phase(), src/macp_sdk/base_projection.py:289-313). So 'Committed' is terminal for phase on every projection, not only DecisionProjection: once reached, phase cannot regress even if a caller violates the accepted-only input contract and a phase-moving envelope is replayed afterward. The one deliberate bypass is BaseProjection.applyEnvelope's own direct assignment to 'Committed' for a Commitment envelope — exact parity with Python's own _set_phase() docstring, which documents the same exception.

DecisionProjection

Phases: 'Proposal' → 'Evaluation' → 'Voting' → 'Committed'. 'Committed' is terminal for phase: once reached, a Vote cannot legally follow in accepted history (RFC-MACP-0001 §7.2/§7.3 session terminality), and if one is replayed anyway phase does not regress out of 'Committed'. Separately — RFC-MACP-0007 §5 rule 6: a runtime MUST reject any Proposal/Evaluation/Objection after the first accepted Vote — a replayed Proposal (same proposal_id, a distinct message_id that still passes the message_id dedup gate) can no longer rewind phase from 'Voting' back to 'Evaluation' once voting has begun (issue #153).

PropertyType
proposalsMap<string, DecisionProposalRecord>
evaluationsDecisionEvaluationRecord[]
objectionsDecisionObjectionRecord[]
votesMap<string, Map<string, DecisionVoteRecord>> — proposal_id → sender → the sender's first accepted Vote for that proposal. A later Vote from the same sender for the same proposal is discarded and recorded in anomalies instead of overwriting the entry (RFC-MACP-0007 §5 item 3).
MethodReturnsDescription
voteTotals()Record<string, number>Positive vote counts per proposal, computed from votes — a discarded duplicate is never counted; see the votes row above and Anomalies
majorityWinner()string | undefinedProposal whose positive votes exceed 50% of all non-abstain votes
voteRatio(proposalId)numberApprove ratio, excluding abstains from the denominator
hasBlockingObjection(proposalId?)booleanHas a critical-severity objection (only critical blocks per RFC-MACP-0004 (Security)); omit the ID to check all proposals
reviewEvaluations()DecisionEvaluationRecord[]Evaluations with REVIEW recommendation (informational)
qualifyingEvaluations()DecisionEvaluationRecord[]Evaluations excluding REVIEW

ProposalProjection

Phases: 'Negotiating' → 'TerminalRejected' / 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.

PropertyType
proposalsMap<string, ProposalRecord>
acceptsProposalAcceptRecord[] — full append-only history, including any accept later superseded by the same sender (see isAccepted/acceptedProposal below)
rejectionsProposalRejectRecord[]
MethodReturnsDescription
activeProposals()ProposalRecord[]Proposals with status 'open'
latestProposal()ProposalRecord | undefinedMost recently submitted
isAccepted(proposalId)booleanSome participant's current (unsuperseded) Accept targets this ID — RFC-MACP-0008 §5 rule 5: a later Accept from a participant supersedes their earlier one, so a superseded accept is not counted even though it remains in accepts
isTerminallyRejected(proposalId)booleanHas terminal Reject
liveProposals()Map<string, ProposalRecord>All proposals except withdrawn ones
acceptedProposal()string | undefinedThe single proposal ID every participant's current accept targets; undefined if no one currently has an outstanding accept, or current accepts are split across more than one proposal. Computed from current accepts, not from accepts' full history
hasTerminalRejection()booleanAny terminal Reject in the session

TaskProjection

Phases: 'Pending' → 'Requested' → 'InProgress' → 'Completed' / 'Failed' → 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.

PropertyType
tasksMap<string, TaskRecord>
updatesTaskUpdateRecord[]
completionsTaskCompleteRecord[]
failuresTaskFailRecord[]
rejectionsTaskRejectRecord[] — unconditional audit record of every TaskReject, same convention as updates/completions/failures above

Migrating from 0.12.x: TaskProjection.isComplete(taskId) and the TaskCompletionRecord/TaskFailureRecord type aliases were deprecated in 0.12.0 and removed in 0.13.0. Use isCompleted(taskId) and TaskCompleteRecord/ TaskFailRecord instead — the signatures and semantics are identical. See the 0.13.0 "Removed" entry in CHANGELOG.md.

MethodReturnsDescription
getTask(taskId)TaskRecord | undefinedFull task record — see Task assignee lifecycle for how assignee is set and cleared
progressOf(taskId)numberCurrent progress (0 before any update, 1 once complete)
isCompleted(taskId)booleanTaskComplete received
isFailed(taskId)booleanTaskFail received
isRetryable(taskId)booleanFailed with retryable: true
isAccepted(taskId)booleanStatus is accepted or in_progress
activeTasks()TaskRecord[]Tasks in requested/accepted/in_progress
latestProgress()number | undefinedProgress of the most recent TaskUpdate

Task assignee lifecycle

RFC-MACP-0009 §5 rule 3 (:69) scopes the assignee slot to the Session, not to a task_id: "Only one assignee may become active for the Session in base v1." TaskProjection models it the same way — one session-level slot, matching the reference runtime's single TaskState.active_assignee.

  • First accept wins, per session. The first TaskAccept that names a known task_id takes the slot and sets that task's assignee (rule 3a, :70). Every later TaskAccept for a task_id this projection has a TaskRequest on file for — including one for a different task_id in the same transcript — is discarded while the slot is held, does not advance phase to 'InProgress' on its own, and is recorded in anomalies as duplicate_task_accept. A TaskAccept for an unknown task_id is discarded the same way but records no anomaly (see Anomalies).
  • A reject by the slot holder frees the slot (rule 3c, :72): "the session returns to the pre-assignment state. Other eligible participants MAY then send TaskAccept for the same task_id." TaskProjection clears both the session slot and the assignee on the TaskRecord the slot was taken on, so a subsequent TaskAccept can reassign. A TaskReject from anyone other than the current slot holder leaves the assignment untouched — matching macp-runtime's crates/macp-modes/src/mode/task.rs:257-260, which clears active_assignee only when the active assignee is the rejecter.
  • No session-policy input is needed, and none is taken. Rule 3c's reassignment is gated on the policy flag allow_reassignment_on_reject (RFC-MACP-0012 :135), and the runtime enforces that gate twice — it denies the active assignee's TaskReject and the follow-up TaskAccept with POLICY_DENIED when the flag is false. A projection replays history the runtime already accepted (see the accepted-only input contract), so a reassignment the runtime denied never reaches the transcript. Tracking the reject unconditionally therefore cannot diverge from the runtime on any real transcript, and it avoids threading a PolicyDefinition into the projection constructor.

List accumulators and logical duplicates

evaluations, objections, accepts, rejections, updates, completions, and failures are append-only audit lists, not cardinality-enforcing sets. They are idempotent under redelivery (same message_id — see Redelivery), but two envelopes with different message_ids that describe the same logical record both get appended.

That is deliberate, and it is what the RFCs and the reference runtime do:

SiteStated cardinality rule?Runtime behaviourProjection
Decision EvaluationNone. RFC-MACP-0007 §5 constrains only proposal_id existence (rule 2); no per-sender capdecision.rs:174 pushes to a Vec unconditionallyAppend — correct
Decision ObjectionNone (same)decision.rs:197 pushes to a Vec unconditionallyAppend — correct
Proposal AcceptYes, and it is last-wins: RFC-MACP-0008 §5 rule 5 (:70) — "The latest accepted Accept from a participant supersedes earlier accepts from the same participant"proposal.rs:289-291 keeps a sender → proposal_id map, so the last accept winsaccepts keeps the full audit history; the live acceptance set is tracked separately and drives isAccepted() / acceptedProposal() (§7 determinism)
Proposal RejectNone. Rule 3 requires an existing proposal; rule 6 is about terminal rejection, not countproposal.rs:313 pushes to a Vec — "Always record the rejection for audit trail"Append — correct
Task TaskUpdateNone, and repetition is the point: RFC-MACP-0009 §4 defines it as a "non-terminal progress or status update"task.rs:278 pushes to a Vec; the only guards are active-assignee authorship and no terminal report yetAppend — correct
Task TaskCompleteEffectively at most one per session — RFC-MACP-0009 §5 rule 5 treats TaskComplete/TaskFail as the terminal reporttask.rs:293-297 returns FORBIDDEN once terminal_report is set, so a second terminal report never enters accepted historyAppend — a conforming transcript can never carry a second one
Task TaskFailSame as TaskComplete (they share one terminal_report slot)task.rs:314-318, same guardAppend — same

So: no site has a rule the runtime fails to enforce, and no site needs a projection-side guard. Where a rule does exist and the derived view would otherwise be wrong — Proposal's last-accept-wins — it is already modelled.

HandoffProjection

Phases: 'Pending' → 'OfferPending' → 'ContextSharing' → 'Accepted' / 'Declined' → 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.

PropertyType
handoffsMap<string, HandoffRecord>
MethodReturnsDescription
getHandoff(handoffId)HandoffRecord | undefinedFull handoff record (.implicit set once accepted) — status settles once: once a handoff_id transitions to 'accepted' or 'declined', a later contradictory HandoffAccept/HandoffDecline for the same ID is ignored and recorded in anomalies as settled_handoff (RFC-MACP-0010 §5 rule 4, §5.1(4)); a HandoffAccept/HandoffDecline for an unknown handoff_id is also ignored but records no anomaly (see Anomalies)
isAccepted(handoffId)booleanHandoffAccept received
isImplicitlyAccepted(handoffId)booleanAccepted by a runtime synthetic implicit accept (RFC-MACP-0010 (Handoff Mode) §5.1, proto ≥ 0.1.6)
isDeclined(handoffId)booleanHandoffDecline received
pendingHandoffs()HandoffRecord[]Handoffs in offered/context_sent status
hasAcceptedOffer(handoffId?)booleanGiven handoff accepted, or (with no ID) any handoff accepted
activeOffer()HandoffRecord | undefinedMost recent handoff still in offered/context_sent status

QuorumProjection

Phases: 'Pending' → 'Voting' → 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.

PropertyType
requestsMap<string, ApprovalRequestRecord>
ballotsMap<string, Map<string, BallotRecord>> — requestId → sender → the sender's first accepted ballot (Approve/Reject/Abstain) for that request. A later ballot of any type from the same sender for the same request is discarded and recorded in anomalies instead of overwriting the entry. RFC-MACP-0011 §5 rule 3 states this first-wins behavior directly ("the first accepted ballot stands"), since the rule-3 hardening in spec PR multiagentcoordinationprotocol#85 — first-wins here was an inference from parity with RFC-MACP-0007 §5 item 3 plus runtime-enforced behaviour before that hardening; see Anomalies.
MethodReturnsDescription
approvalCount(requestId)numberCount of approve ballots, computed from ballots — a discarded duplicate is never counted; see the ballots row above and Anomalies
rejectionCount(requestId)numberCount of reject ballots, computed from ballots — same duplicate exclusion as approvalCount
abstentionCount(requestId)numberCount of abstain ballots, computed from ballots — same duplicate exclusion as approvalCount
hasQuorum(requestId)booleanApprovals >= required threshold
threshold(requestId)numberRequired approvals for this request
remainingVotesNeeded(requestId)numbermax(0, required - approvalCount) — derived from approvalCount, so it inherits the same duplicate exclusion; see the ballots row above and Anomalies
votedSenders(requestId)string[]Senders who have voted
commitmentReady(requestId)booleanQuorum reached and not yet committed
isThresholdUnreachable(requestId, totalEligible)booleanEven if all remaining eligible voters approve, the threshold cannot be met