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:
| Property | Type | Description |
|---|---|---|
transcript | Envelope[] | The session's accepted history as a conforming runtime holds it: one envelope per unique message_id, in application order. See Redelivery below. |
phase | string literal union | Current session phase |
commitment | Record<string, unknown> | undefined | Commitment payload, set on Commitment |
isCommitted | boolean (getter) | true once a Commitment has been applied |
isPositiveOutcome | boolean | undefined (getter) | Commitment's outcomePositive; undefined before commit, true when the field is absent |
anomalies | ProjectionAnomaly[] | Cardinality anomalies recorded while replaying the accepted transcript. See Anomalies below. |
hasAnomalies | boolean (getter) | true once at least one anomaly has been recorded |
All projections implement:
applyEnvelope(envelope: Envelope, protoRegistry: ProtoRegistry): voidThis method:
- Checks the envelope's mode matches (ignores others)
- Appends to
transcript - Decodes the payload via
protoRegistry.decodeKnownPayload() - 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 distinctmessage_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_idis never deduped. The guard is gated onif (envelope.messageId); an id-less envelope always applies. This matters for hand-built envelopes from callers who don't setmessageId— 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, neverwarn— 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 aSet<string>of ids is strictly dominated by that; sessions are also TTL-bounded by protocol. GrpcTransportAdapteris covered end to end (it is the path the shared projection instance below is about).HttpTransportAdapter's array (Python-style polling) branch normalizesmessage_id→messageIdbefore 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:
applyEnvelopehas always silently dropped envelopes for a different mode (theif (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
Participanthappy path, the shared projection instance below means the initiator's own envelope is naturally applied twice — once locally on ACK via the mode session'ssendAndTrack, once again via replayed transport history — so a consumer treatingtranscriptas 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
Votefrom this sender for this proposal (duplicate_vote, RFC-MACP-0007 §5.3) — the first stands. - A second distinct ballot across
Approve/Reject/Abstainfrom 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
TaskAcceptfor a task this projection has aTaskRequeston 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;subjectIdnames the losingtask_id, not the holder's. - A
HandoffAccept/HandoffDeclinefor ahandoff_idthat 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:
- Pushes the
ProjectionAnomalyontoanomalies— the canonical, cross-SDK agreed semantic. Read this array; it is what you should build logic on. - Emits
logger.warn('projection anomaly', anomaly)— this half is explicitly non-contractual observability and may differ per SDK.logger.warnis visible by default (this SDK's default log level iswarn, seesrc/logging.ts): observing and discarding a duplicate is deliberately not silent. If you need to quiet the log line without losing theanomaliesarray, raise the log level —configureLogging({ level: 'error' })or theMACP_LOG_LEVELenvironment 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).
| Property | Type |
|---|---|
proposals | Map<string, DecisionProposalRecord> |
evaluations | DecisionEvaluationRecord[] |
objections | DecisionObjectionRecord[] |
votes | Map<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). |
| Method | Returns | Description |
|---|---|---|
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 | undefined | Proposal whose positive votes exceed 50% of all non-abstain votes |
voteRatio(proposalId) | number | Approve ratio, excluding abstains from the denominator |
hasBlockingObjection(proposalId?) | boolean | Has 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.
| Property | Type |
|---|---|
proposals | Map<string, ProposalRecord> |
accepts | ProposalAcceptRecord[] — full append-only history, including any accept later superseded by the same sender (see isAccepted/acceptedProposal below) |
rejections | ProposalRejectRecord[] |
| Method | Returns | Description |
|---|---|---|
activeProposals() | ProposalRecord[] | Proposals with status 'open' |
latestProposal() | ProposalRecord | undefined | Most recently submitted |
isAccepted(proposalId) | boolean | Some 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) | boolean | Has terminal Reject |
liveProposals() | Map<string, ProposalRecord> | All proposals except withdrawn ones |
acceptedProposal() | string | undefined | The 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() | boolean | Any terminal Reject in the session |
TaskProjection
Phases: 'Pending' → 'Requested' → 'InProgress' → 'Completed' / 'Failed' → 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.
| Property | Type |
|---|---|
tasks | Map<string, TaskRecord> |
updates | TaskUpdateRecord[] |
completions | TaskCompleteRecord[] |
failures | TaskFailRecord[] |
rejections | TaskRejectRecord[] — unconditional audit record of every TaskReject, same convention as updates/completions/failures above |
Migrating from 0.12.x:
TaskProjection.isComplete(taskId)and theTaskCompletionRecord/TaskFailureRecordtype aliases were deprecated in0.12.0and removed in0.13.0. UseisCompleted(taskId)andTaskCompleteRecord/TaskFailRecordinstead — the signatures and semantics are identical. See the0.13.0"Removed" entry inCHANGELOG.md.
| Method | Returns | Description |
|---|---|---|
getTask(taskId) | TaskRecord | undefined | Full task record — see Task assignee lifecycle for how assignee is set and cleared |
progressOf(taskId) | number | Current progress (0 before any update, 1 once complete) |
isCompleted(taskId) | boolean | TaskComplete received |
isFailed(taskId) | boolean | TaskFail received |
isRetryable(taskId) | boolean | Failed with retryable: true |
isAccepted(taskId) | boolean | Status is accepted or in_progress |
activeTasks() | TaskRecord[] | Tasks in requested/accepted/in_progress |
latestProgress() | number | undefined | Progress 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
TaskAcceptthat names a knowntask_idtakes the slot and sets that task'sassignee(rule 3a,:70). Every laterTaskAcceptfor atask_idthis projection has aTaskRequeston file for — including one for a differenttask_idin the same transcript — is discarded while the slot is held, does not advancephaseto'InProgress'on its own, and is recorded inanomaliesasduplicate_task_accept. ATaskAcceptfor an unknowntask_idis 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 sendTaskAcceptfor the sametask_id."TaskProjectionclears both the session slot and theassigneeon theTaskRecordthe slot was taken on, so a subsequentTaskAcceptcan reassign. ATaskRejectfrom anyone other than the current slot holder leaves the assignment untouched — matchingmacp-runtime'scrates/macp-modes/src/mode/task.rs:257-260, which clearsactive_assigneeonly 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'sTaskRejectand the follow-upTaskAcceptwithPOLICY_DENIEDwhen 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 aPolicyDefinitioninto 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:
| Site | Stated cardinality rule? | Runtime behaviour | Projection |
|---|---|---|---|
Decision Evaluation | None. RFC-MACP-0007 §5 constrains only proposal_id existence (rule 2); no per-sender cap | decision.rs:174 pushes to a Vec unconditionally | Append — correct |
Decision Objection | None (same) | decision.rs:197 pushes to a Vec unconditionally | Append — correct |
Proposal Accept | Yes, 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 wins | accepts keeps the full audit history; the live acceptance set is tracked separately and drives isAccepted() / acceptedProposal() (§7 determinism) |
Proposal Reject | None. Rule 3 requires an existing proposal; rule 6 is about terminal rejection, not count | proposal.rs:313 pushes to a Vec — "Always record the rejection for audit trail" | Append — correct |
Task TaskUpdate | None, 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 yet | Append — correct |
Task TaskComplete | Effectively at most one per session — RFC-MACP-0009 §5 rule 5 treats TaskComplete/TaskFail as the terminal report | task.rs:293-297 returns FORBIDDEN once terminal_report is set, so a second terminal report never enters accepted history | Append — a conforming transcript can never carry a second one |
Task TaskFail | Same as TaskComplete (they share one terminal_report slot) | task.rs:314-318, same guard | Append — 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.
| Property | Type |
|---|---|
handoffs | Map<string, HandoffRecord> |
| Method | Returns | Description |
|---|---|---|
getHandoff(handoffId) | HandoffRecord | undefined | Full 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) | boolean | HandoffAccept received |
isImplicitlyAccepted(handoffId) | boolean | Accepted by a runtime synthetic implicit accept (RFC-MACP-0010 (Handoff Mode) §5.1, proto ≥ 0.1.6) |
isDeclined(handoffId) | boolean | HandoffDecline received |
pendingHandoffs() | HandoffRecord[] | Handoffs in offered/context_sent status |
hasAcceptedOffer(handoffId?) | boolean | Given handoff accepted, or (with no ID) any handoff accepted |
activeOffer() | HandoffRecord | undefined | Most recent handoff still in offered/context_sent status |
QuorumProjection
Phases: 'Pending' → 'Voting' → 'Committed'. 'Committed' is terminal for phase here too — see BaseProjection.
| Property | Type |
|---|---|
requests | Map<string, ApprovalRequestRecord> |
ballots | Map<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. |
| Method | Returns | Description |
|---|---|---|
approvalCount(requestId) | number | Count of approve ballots, computed from ballots — a discarded duplicate is never counted; see the ballots row above and Anomalies |
rejectionCount(requestId) | number | Count of reject ballots, computed from ballots — same duplicate exclusion as approvalCount |
abstentionCount(requestId) | number | Count of abstain ballots, computed from ballots — same duplicate exclusion as approvalCount |
hasQuorum(requestId) | boolean | Approvals >= required threshold |
threshold(requestId) | number | Required approvals for this request |
remainingVotesNeeded(requestId) | number | max(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) | boolean | Quorum reached and not yet committed |
isThresholdUnreachable(requestId, totalEligible) | boolean | Even if all remaining eligible voters approve, the threshold cannot be met |