The Quorum / Voting Pattern, Implemented via the Reactive Reducer
A little background
Eleven patterns in. The debate article had the agents talk - competing positions, critique, revision, a judge. This one is the opposite: the agents never talk at all. Each one evaluates the same question in isolation, writes a vote, and the votes are aggregated by a rule. That’s the quorum / voting pattern - and it’s the cleanest demonstration yet of what the reactive reducer actually is, because the aggregation isn’t a prompt. It’s a guard.
In most frameworks, the quorum is a step in the workflow: collect the votes, then a synthesizer or judge agent reads them and decides the outcome. The voting rule lives in that agent’s prompt - “take the majority” - and you trust the model to actually count correctly. The reducer removes that trust step. The votes are typed context fields. The majority is a CEL expression on the join edge. The machine fires the edge whose guard passes. No model reads the votes and decides. The quorum is applied, not performed.
The pattern
Quorum / Voting. Multiple agents independently evaluate the same question, classification, or decision, and their outputs are aggregated using a voting or quorum rule. The agents may never communicate with one another; independence is often desirable because the goal is to reduce reliance on any single model or reasoning path. Votes can be simple majority decisions or weighted by confidence, expertise, model quality, or historical performance.
Good for:
- High-confidence decisions
- Classification
- Validation
- Reducing single-model variance
- Safety or quality gates
- Ensemble-style reasoning
Common failure points:
- Correlated agents produce correlated errors
- Majority agreement does not guarantee correctness
- Duplicate inference increases cost
- Confidence scores may be poorly calibrated
- Weak or homogeneous agent diversity limits the value of voting
The machine: the votes are typed fields, the majority is a guard
A production deployment gate. The question: should this deployment be approved? Three independent reviewers - a security reviewer, a performance reviewer, and a reliability reviewer - each evaluate the deployment in isolation and vote approve or reject, with a confidence. The reducer collects the votes and applies the majority.
fsm:
name: deployment-quorum
version: 1
initialState: question
context:
question: { type: string } # templated into every voter
votes: { type: map, of: enum, values: [approve, reject] } # one field, keyed by voter
confidence: { type: map, of: integer } # 0-100, keyed the same way
rationale: { type: string } # which votes drove the outcome
answer: { type: string } # final decision
agents:
chair: { canTransition: [question, approve, reject] }
states:
question:
description: >-
Stage the decision: updateContext(field=question, value=<the deployment question,
with the diff summary and the approval criteria>).
requires:
- { name: question, from: { agent: chair } }
transitions:
- { toState: voting }
voting:
description: >-
Evaluate "{{ context.question }}" independently. Do NOT look at the other reviewers -
vote on your own assessment alone. Stage updateContext(field=votes, value=approve|reject)
and updateContext(field=confidence, value=<0-100>).
requires:
# Same two fields, three named voters. Entries are keyed by the actual writer, so each
# requirement is satisfied only by its own reviewer - one agent cannot cast two votes.
- { name: votes, from: { agent: security }, prompt: "Your vote: approve or reject." }
- { name: confidence, from: { agent: security }, prompt: "Your confidence, 0-100." }
- { name: votes, from: { agent: perf }, prompt: "Your vote: approve or reject." }
- { name: confidence, from: { agent: perf }, prompt: "Your confidence, 0-100." }
- { name: votes, from: { agent: reliability }, prompt: "Your vote: approve or reject." }
- { name: confidence, from: { agent: reliability }, prompt: "Your confidence, 0-100." }
transitions:
# No join guard: the six required map entries are the join.
- { toState: tally }
tally:
description: >-
Pure decision node - no agent acts here. The reducer evaluates the quorum guards and
fires the edge that passes. With three binary votes, one side always holds a majority.
transitions:
- toState: approve
guards:
- backend: cel
expression: "size(context.votes.filter(k, context.votes[k] == 'approve')) >= 2"
- toState: reject
guards:
- backend: cel
expression: "size(context.votes.filter(k, context.votes[k] == 'reject')) >= 2"
approve:
description: >-
The quorum approved. Compose the rationale from the votes and confidences, then the answer.
Stage: updateContext(field=rationale, value=<which reviewers approved, at what confidence>)
and updateContext(field=answer, value=<APPROVED: <summary>>).
requires:
- { name: rationale, from: { agent: chair } }
- { name: answer, from: { agent: chair } }
transitions:
- { toState: done }
reject:
description: >-
The quorum rejected. Compose the rationale from the votes and confidences, then the answer.
Stage: updateContext(field=rationale, value=<which reviewers rejected, at what confidence>)
and updateContext(field=answer, value=<REJECTED: <summary>>).
requires:
- { name: rationale, from: { agent: chair } }
- { name: answer, from: { agent: chair } }
transitions:
- { toState: done }
done:
description: "Decision delivered."
terminal: true
policy:
maxExchangesPerStage: 30
maxExchangesPerAgentPerStage: 15
checkpointEvery: 5
Quorum / Voting work flow:
Three things to hold onto:
- Independence is structural, not a prompt. The
votingstate parks and summons all three reviewers concurrently, and each one’s objective is templated onquestionalone - the snapshot input. A reviewer never sees the others’ votes, because they don’t exist yet (they’re being written in parallel) and the objective doesn’t reference them. The “agents may never communicate” property the pattern wants isn’t a convention you hope the models follow - it’s the topology. The reducer gives each voter the question and nothing else. - The ballot box is one field, and it counts itself.
votesis amapand every entry is keyed by whoever actually wrote it - the runtime forces the key and rejects an explicit one on a field a named requirement is gathering, so a reviewer can’t file a vote under a colleague’s name or vote twice. That’s what lets three requirements name the same field and still form a real join: the state is ineligible until all three named keys exist. Compare the alternative, which is what this article used to show - one scalar field per voter, and a guard that has to be edited every time the panel changes size. - The majority is a guard, not a judgment. The
tallystate is a pure decision node: no agent, norequires, no objective. The reducer enters it, evaluates the two quorum guards, and fires the one that passes. And the guards count -size(votes.filter(k, votes[k] == 'approve')) >= 2is the quorum rule itself, not an enumeration of the winning combinations. Change the panel to five and the rule becomes>= 3; the old shape needed ten disjuncts. The rule is declared in the YAML, readable before the run, and applied by the machine. No model reads the votes and decides. The chair only acts after the outcome, to compose the rationale from votes already in the store.
One typing note, because it’s the kind of thing that fails quietly: confidence is an integer (0-100), not a decimal. There is no number type - a field declared with one falls through to string, and the write succeeds, and then a guard comparing it numerically errors, and an errored guard is treated as not firing. The failure surfaces as an edge that mysteriously never takes. Integers compare; strings that look like numbers don’t.
Walking one gate
The chair stages the question: a diff that adds a new payment endpoint, with the approval criteria (no plaintext secrets, p99 under 200ms, retry-safe). voting parks and summons all three reviewers at once, each templated on the question alone. The security reviewer finds no secrets and votes approve at 90; the performance reviewer estimates p99 at 310ms and votes reject at 70; the reliability reviewer confirms the retry logic is idempotent and votes approve at 80. Each write lands under its author’s key, and when all six entries are in the state’s requirements are satisfied. The machine moves to tally.
tally has no agent. The reducer counts: size(votes.filter(k, votes[k] == 'approve')) is 2, which clears the threshold, so the approve edge fires. Note that nothing enumerated which two - the guard is the quorum rule itself, so a five-reviewer panel would be the same expression with a 3. The machine moves to approve, summons the chair, who reads both maps side by side and composes rationale = "security (90) and reliability (80) approved; performance (70) rejected on p99" and answer = "APPROVED: 2/3 quorum - ship with a p99 follow-up". done.
What the reducer does to the failure points
Correlated agents produce correlated errors. The reducer can’t fix correlation - if all three reviewers are the same model with the same blind spot, they’ll make the same error and the majority will be confidently wrong. That’s a diversity problem, not a mechanism problem. But the reducer makes the correlation visible and countable: the votes sit in one map keyed by voter, so three identical votes at identical confidence are one lookup away, and a guard can even act on it (size(context.confidence.filter(k, context.confidence[k] >= 90)) == 3 is a perfectly good “suspiciously unanimous” tripwire). And the fan-out is heterogeneity-ready - the three voters can be three different models, three different prompts, or three different toolsets, all summoned into the same voting state. The mechanism guarantees independent evaluation; the diversity of the voters is your design choice, and the store shows you whether it paid off.
Majority agreement does not guarantee correctness. True - a 2/3 majority is a confidence signal, not a correctness proof. The reducer doesn’t claim otherwise. What it does is make the majority exact and inspectable: the quorum is a declared guard, the votes are typed fields, and the rationale field captures which reviewers drove the outcome and at what confidence. A wrong majority is legible - you can see the two votes that won and the one that lost, and you can see the confidence behind each. The fix for a wrong majority is better voters or a different rule (weighted, veto, unanimous), not a different aggregation shape. The aggregation is already exact.
Duplicate inference increases cost. Yes - quorum is duplicated inference, by design. Three reviewers do the work one could do alone. The cost is the price of the pattern’s benefit: reduced reliance on a single model. The reducer makes the cost bounded and visible: the voting state parks until all three votes are in, and the parked record shows how long the quorum took and how many inferences it ran. And because the voters run concurrently, the wall-clock cost is the slowest voter, not the sum - the same latency trade as the fan-out article. If the cost isn’t worth it for a given decision, you don’t run a quorum; you run a single reviewer. The quorum is a tool you reach for when confidence matters more than cost, not a default you pay for on every decision.
Confidence scores may be poorly calibrated. confidence is a map of integer keyed by voter, so a claim of 95 on a vote that turned out wrong is attributable to the voter who made it - which is the precondition for recalibrating anything. And because the values really are integers rather than numeric-looking strings, a weighted quorum is expressible as a guard rather than a prompt: confidence['agent:security'] >= 80 && votes['agent:security'] == 'approve' for a high-confidence veto, or a sum over the approvers for a threshold rule. The type is what makes this work; a field declared number would land as a string and the arithmetic guard would error, which the reducer treats as “does not fire”. The calibration problem is the voters’; the rule is exact and declared either way.
Weak or homogeneous agent diversity limits the value of voting. This is the same as the first point, stated from the design side. The value of a quorum is only as good as the independence of the voters, and the reducer’s contribution is that the independence is structural: the voters are summoned concurrently on the same snapshot, with no access to each other’s votes. Homogeneous voters (same model, same prompt) are a design choice you can see in the YAML - three requirements naming three agents that happen to be the same model is a correlated quorum, written down. The reducer doesn’t stop you; it makes the choice explicit where you can review it before the run.
Two honest trade-offs. This is a one-shot quorum: the voters vote once, the majority fires, the decision is delivered. There’s no revision round (that’s debate), no re-vote after new information (that’s a planner/executor re-plan). If you need the voters to react to each other’s positions, you want debate, not quorum. And if you did re-run the vote, note what re-arming costs: the loop-back edge has to remove: { votes: "*" } and the same for confidence, because an entry that survives the round satisfies its requirement, and the second round would tally the first round’s ballots without asking anyone. Accumulated state is only safe where it’s meant to accumulate.
The whole thing, one idea
| Moment | Who | What happened in context |
|---|---|---|
| question | chair | question set |
| voting | 3 reviewers (concurrent) | votes + confidence keyed by each reviewer |
| tally | - (no agent) | size(filter(approve)) >= 2 fires |
| approve | chair | rationale + answer set |
| done | - | terminal |
No judge reading the votes. No synthesizer deciding the outcome. The reviewers voted in isolation - independence was the topology, not a prompt. The votes landed in one typed field, keyed by the runtime to whoever cast them, so the join and the attribution were the same mechanism. The majority was a declared CEL expression that counts rather than enumerating combinations, so the panel’s size is a number in a guard instead of a shape in the graph. The quorum was applied, not performed - and the machine was the only thing that moved it.
Up next: 13. Dynamic Team Formation
Series:
- Intro
- 1. Sequential Pipeline
- 2. Parallel Fan-Out / Fan-In
- 3. Supervisor / Router
- 4. Hierarchical Manager / Worker
- 5. Peer Handoff / Swarm
- 6. Debate / Consensus
- 7. Blackboard / Shared-State
- 8. Planner / Executor
- 9. Generator / Critic / Refiner
- 10. Auction / Contract-Net
- 11. Event-Driven / Publish-Subscribe
- 12. Quorum / Voting
- 13. Dynamic Team Formation