Federated Quorum
Status & scope
- Stage: DRAFT — ready to implement
- Module:
parallax/ops/fusion/federation/quorum.py(new) + additions toparallax/ops/fusion/models/lens_spec.py - Depends on: scoring-engine, correlation-persistence, three-phase-protocol, existing
LineageAction+CorrelationStatusenums - Part of: Full Fusion Cycle initiative · enables the dissent-fidelity benchmark (themis FED-SPEC-03.2, external)
- Milestone: M6 (Federation Protocol)
- Definition of done:
pytest tests/test_quorum.pygreen — all acceptance boxes checked
Problem
The three-phase protocol (SPEC-16) and run_multi_fusion (SPEC-16 §13) produce a
match verdict per node pair and then recover transitivity with UnionFind. There is
no notion of multiple federated nodes agreeing on a single correlation. When N
nodes each hold an opinion on whether record X and record Y are the same entity,
today's pipeline silently merges those opinions: UnionFind unions any pair above
threshold, and the per-node verdicts that disagreed are lost.
Four failure modes this creates:
- No declared agreement rule. A lens cannot say "this correlation is only CONFIRMED if a majority of participating nodes agree." Agreement is implicit in whatever the clustering step happens to do, which is not auditable and not configurable per lens.
- Absence is conflated with rejection. When a node is offline or times out
during a fusion run, its silence is treated identically to a
no_matchverdict. The audit trail cannot distinguish "node B said these are different people" from "node B never answered."FusionRun.missing_federatesrecords that a node was absent at the run level, but nothing records per correlation that the node abstained. - No quorum outcome on the record. A
CorrelationRecordcarries a singleconfidenceandstatus. There is no field that says "4 of 5 nodes confirmed this; the quorum policy was majority; the policy was satisfied." A reviewer cannot reconstruct how a multi-party decision was reached. - Dissent has nothing to attach to. SPEC-30 (Dissent Recording) needs a well-defined "the quorum decided X" event so it can record the verdicts that disagreed with X. Without an explicit quorum outcome, "dissent" is undefined.
themis FED-SPEC-03 §4 makes this the gating requirement for the dissent fidelity
benchmark (FED-SPEC-03.2): "when contributors disagree, both perspectives survive
in the audit chain with attribution." That benchmark is build status in
FED-SPEC-12 precisely because SPEC-29 quorum + SPEC-30 dissent do not exist yet.
Decision
Add an explicit, declarative quorum layer between per-node verdicts and the correlation outcome.
- Quorum policy is declared in the lens. A new
QuorumConfigonLensSpec(or, when running ad-hoc, passed to the evaluator directly) declares the policy:unanimous,majority,weighted, orn_of_m. No default that silently confirms — a lens with no quorum config falls back to the existing bilateral pipeline behaviour, unchanged. - Quorum evaluation is a pure function over per-node verdicts.
evaluate_quorum()takes a tuple ofNodeVerdictand aQuorumConfig, and returns aQuorumOutcome. No I/O, no infrastructure, no clock dependency beyond an injectable timestamp. Deterministic: the same verdicts and policy always produce the same outcome. - Absence is a first-class verdict:
ABSTAIN. A node that is absent, timed out, or declined to participate is recorded asNodeVerdict(vote=ABSTAIN), never asNO_MATCH. The quorum policy decides how abstentions count (most policies treat them as non-votes against the denominator; see §"Partial participation"). - The quorum outcome is recorded on the correlation as an append-only event.
A new
LineageAction.QUORUM_EVALUATEDevent carries the policy, the tally, the per-node verdicts, and the resulting decision. TheCorrelationRecord.statustransitions per the outcome (CONFIRMED / PROPOSED / REJECTED), but the event is the truth — the status is a materialisation. - No auto-routing, no state machine.
evaluate_quorum()computes an outcome andrecord_quorum_outcome()appends an event. Neither advances a workflow, notifies a human, or triggers downstream actions. AI assists; a human still attests (Invariant 6). A satisfied quorum produces a PROPOSED-or-CONFIRMED correlation that a human may still attest, invalidate, or correct (SPEC-24).
Architecture
Quorum sits after Phase 3 consensus scoring (SPEC-16 §3) and before correlation persistence (SPEC-11). It is the bridge from "N nodes each scored this pair" to "the federation has an attributable, policy-governed opinion on this pair."
SPEC-16 Phase 3 (per-pair, per-node scores)
│
▼
collect_verdicts() ── one NodeVerdict per participating node,
│ ABSTAIN for absent/timed-out nodes
▼
evaluate_quorum(verdicts, policy) ── PURE FUNCTION
│
▼
QuorumOutcome ── {decision, policy, tally, dissenting_node_ids, ...}
│
├──► record_quorum_outcome() ── append QUORUM_EVALUATED event,
│ transition CorrelationRecord.status
│
└──► SPEC-30 record_dissent() ── for every verdict that disagrees
with the quorum decision
Standalone mode: collect_verdicts() is fed from the per-node, per-pair scores
already produced by run_multi_fusion / the three-phase pipeline (both run
in-process, no infrastructure). Platform mode: the same NodeVerdict tuple is
assembled by titan's coordinator from the FUSION_RUN_COMPLETE messages each
node returns. parallax never sees the wire — it sees the verdict tuple.
Partial participation
A federated run never guarantees every node answers. SPEC-29 treats non-response as information, not failure.
- A node that times out, is offline, or declines (no consent, policy boundary) yields
NodeVerdict(vote=ABSTAIN, reason=...)for every pair it would have voted on. count_abstentions_ascontrols the arithmetic:"non_vote"(default): abstentions are excluded fromparticipants(the denominator). A 3-node majority among 5 declared nodes where 2 abstained needs 2 of the 3 voters."against": abstentions count asNO_MATCHfor the agreement test (strict mode for lenses where silence should block confirmation).- If
participants < min_participants, the decision isINDETERMINATEand the correlation staysPROPOSED— never auto-confirmed on thin participation. - Consistency with the run-level audit: a node in
FusionRun.missing_federatesproducesABSTAINverdicts in every correlation it touched, and the run isstatus == "partial"by SPEC-16 §7 construction.
Public API
Configuration model (additions to lens_spec.py)
# parallax/ops/fusion/models/lens_spec.py (additions)
class QuorumPolicy(str, Enum):
UNANIMOUS = "unanimous" # every participating (non-abstaining) node must agree
MAJORITY = "majority" # > 50% of participating nodes agree
WEIGHTED = "weighted" # sum of weights of agreeing nodes >= weight_threshold
N_OF_M = "n_of_m" # at least `min_agreeing` nodes agree
@dataclass(frozen=True)
class QuorumConfig:
"""Declares how N federated nodes reach agreement on a correlation.
Declared in the lens YAML under `identity_fusion.quorum`, or passed
directly to evaluate_quorum() for ad-hoc runs. Absent => no quorum
layer; the pipeline behaves exactly as it does today (SPEC-16 §13).
"""
policy: QuorumPolicy
# N_OF_M:
min_agreeing: int = 0
# WEIGHTED:
node_weights: tuple[tuple[str, float], ...] = () # (node_id, weight) pairs
weight_threshold: float = 0.0
# Shared:
min_participants: int = 2 # below this, quorum is INDETERMINATE
count_abstentions_as: str = "non_vote" # "non_vote" | "against"
# Per-node agreement is decided against this threshold.
confirmation_threshold: float = 0.70
QuorumConfig is attached to IdentityFusionConfig as an optional field
(quorum: QuorumConfig | None = None), so existing lenses parse unchanged.
Verdict + outcome models (new — federation/quorum.py)
# parallax/ops/fusion/federation/quorum.py (new module)
class NodeVote(str, Enum):
MATCH = "match" # node scored the pair >= confirmation_threshold
NO_MATCH = "no_match" # node scored the pair < confirmation_threshold
ABSTAIN = "abstain" # node absent / timed out / declined — NOT a no_match
@dataclass(frozen=True)
class NodeVerdict:
"""One federated node's opinion on a single candidate pair.
Pure data. Assembled from SPEC-16 Phase 3 per-node scores (standalone)
or from titan FUSION_RUN_COMPLETE messages (platform). `score` is the
node's confidence in [0,1]; for ABSTAIN it is 0.0 and `reason` explains
the absence ("timeout", "offline", "declined", "no_consent").
"""
node_id: str
vote: NodeVote
score: float = 0.0
per_field_scores: tuple[tuple[str, float], ...] = ()
reason: str = "" # populated for ABSTAIN
lens_version: str = ""
timestamp: str = ""
class QuorumDecision(str, Enum):
CONFIRMED = "confirmed" # quorum satisfied for MATCH
REJECTED = "rejected" # quorum satisfied for NO_MATCH
NOT_REACHED = "not_reached" # policy not satisfied either way -> stays PROPOSED
INDETERMINATE = "indeterminate" # fewer than min_participants voted
@dataclass(frozen=True)
class QuorumTally:
match_votes: int
no_match_votes: int
abstentions: int
participants: int # match + no_match (excludes abstentions
# when count_abstentions_as == "non_vote")
match_weight: float = 0.0 # WEIGHTED policy only
no_match_weight: float = 0.0
@dataclass(frozen=True)
class QuorumOutcome:
"""Result of evaluating a quorum policy over a set of NodeVerdicts.
Frozen and self-describing: carries enough to reconstruct WHY the
decision was reached without re-running the evaluator. This is the
object recorded on the correlation and read by SPEC-30.
"""
decision: QuorumDecision
policy: QuorumPolicy
tally: QuorumTally
verdicts: tuple[NodeVerdict, ...] # all node verdicts, in node_id order
agreeing_node_ids: tuple[str, ...] # nodes that voted with the decision
dissenting_node_ids: tuple[str, ...] # voted AGAINST the decision (drives SPEC-30)
abstaining_node_ids: tuple[str, ...]
threshold_detail: str = "" # human-readable rule that fired
Evaluation — the pure function
def evaluate_quorum(
verdicts: tuple[NodeVerdict, ...],
config: QuorumConfig,
) -> QuorumOutcome:
"""Evaluate a quorum policy over per-node verdicts. PURE.
- Deterministic: identical (verdicts, config) -> identical QuorumOutcome.
- No I/O, no clock, no infrastructure.
- ABSTAIN verdicts are partitioned out per config.count_abstentions_as:
"non_vote" -> excluded from participants/denominator
"against" -> counted as NO_MATCH for the agreement test
- If participants < config.min_participants -> decision = INDETERMINATE.
- Policy rules:
UNANIMOUS : every participant voted MATCH -> CONFIRMED;
every participant voted NO_MATCH -> REJECTED; else NOT_REACHED.
MAJORITY : match_votes > participants/2 -> CONFIRMED;
no_match_votes > participants/2 -> REJECTED; else NOT_REACHED.
N_OF_M : match_votes >= min_agreeing -> CONFIRMED;
no_match_votes >= min_agreeing -> REJECTED; else NOT_REACHED.
WEIGHTED : match_weight >= weight_threshold -> CONFIRMED;
no_match_weight >= weight_threshold -> REJECTED; else NOT_REACHED.
- dissenting_node_ids = participants whose vote != the decision's vote.
(For NOT_REACHED / INDETERMINATE there is no decision, so there is no
dissent yet — dissent is defined only relative to a reached decision.)
Raises QuorumConfigError for incoherent config (e.g. N_OF_M with
min_agreeing == 0, WEIGHTED with empty node_weights).
"""
def collect_verdicts(
pair: tuple[str, str],
per_node_scores: dict[str, ScoredPair | None],
expected_nodes: tuple[str, ...],
confirmation_threshold: float,
absent_reason: dict[str, str] | None = None,
timestamp: str = "",
) -> tuple[NodeVerdict, ...]:
"""Assemble NodeVerdicts for one candidate pair.
For each node in expected_nodes:
- score present and >= confirmation_threshold -> MATCH
- score present and < confirmation_threshold -> NO_MATCH
- score is None / node missing -> ABSTAIN with reason
(from absent_reason, default "no_response")
`per_node_scores` is keyed by node_id and produced from SPEC-16 Phase 3
(standalone) or coordinator-assembled FUSION_RUN_COMPLETE results
(platform). Pure — no I/O.
"""
Recording the outcome on the correlation
# parallax/ops/fusion/federation/quorum.py
def record_quorum_outcome(
cr: CorrelationRecord,
outcome: QuorumOutcome,
fusion_run_id: str,
timestamp: str = "",
) -> CorrelationRecord:
"""Append a QUORUM_EVALUATED LineageEntry and transition status.
Append-only (Invariant 2): never rewrites prior lineage. The event's
`details` carries the serialised tally, policy, and per-node verdicts so
the outcome is reconstructable from the ledger alone.
Status transition (materialisation of the event, never the source of truth):
CONFIRMED -> CorrelationStatus.CONFIRMED, accuracy CONFIRMED(+3)
REJECTED -> CorrelationStatus.REJECTED, accuracy KNOWN_WRONG(-3)
NOT_REACHED -> CorrelationStatus.PROPOSED (unchanged; awaits more
nodes or human attestation)
INDETERMINATE -> CorrelationStatus.PROPOSED (insufficient participation)
Does NOT auto-route, notify, or advance any workflow. A human may still
attest/invalidate/correct afterwards (SPEC-24); quorum is machine
agreement, attestation is human sign-off (Invariant 6).
"""
New LineageAction value
# extends LineageAction enum (correlation.py)
class LineageAction(str, Enum):
# ... existing ...
QUORUM_EVALUATED = "quorum_evaluated" # NEW — SPEC-29
New exception
# parallax/ops/fusion/lens/errors.py (addition)
class QuorumConfigError(LensValidationError):
"""A QuorumConfig is internally incoherent or cannot be satisfied:
N_OF_M with min_agreeing <= 0, WEIGHTED with empty node_weights or
weight_threshold <= 0, min_participants < 1, or count_abstentions_as
not in {"non_vote", "against"}. Fail fast at parse/evaluate time."""
Lens YAML surface
identity_fusion:
initial_threshold: 0.50
confirmation_threshold: 0.70
match_function: [...]
quorum: # NEW — optional. Omit => no quorum layer.
policy: majority # unanimous | majority | weighted | n_of_m
min_participants: 2
count_abstentions_as: non_vote # non_vote | against
# n_of_m only:
# min_agreeing: 3
# weighted only:
# node_weights: { sc_central: 2.0, firm_a: 1.0, firm_b: 1.0 }
# weight_threshold: 3.0
parse_lens() (SPEC-01) parses this block into QuorumConfig and validates it
via evaluate_quorum's coherence checks (raising QuorumConfigError). A lens
without a quorum: block parses with IdentityFusionConfig.quorum = None.
Interaction with the Three-Phase Protocol (SPEC-16)
- Quorum consumes the output of Phase 3 (consensus scoring). It does not change any phase. Phase 1/2/3 privacy guarantees are untouched: quorum operates on confidence scores only, never raw PII or feature vectors.
- For a bilateral run (2 nodes),
MAJORITYandUNANIMOUSdegenerate to "both agree." This is the existing SPEC-16 behaviour, now made explicit. - For N-party
run_multi_fusion(SPEC-16 §13), per-pair scores already exist for every node pair.collect_verdicts()reframes a pair's per-node scores into votes;evaluate_quorum()applies the policy. UnionFind clustering is unchanged and still runs — quorum gates which pairwise edges are CONFIRMED before the edge feeds clustering, it does not replace clustering. - A node absent at the run level (
FusionRun.missing_federates) yieldsABSTAINverdicts for every pair it would have voted on. The run-levelFusionRun.status == "partial"and the per-correlationABSTAINrecords are consistent by construction.
Invariants
- Quorum is declarative. The policy lives in the lens (or an explicit
QuorumConfigargument), never hard-coded in the evaluator. - Absence is ABSTAIN, never NO_MATCH. A missing or timed-out node never
counts as a vote against a match.
collect_verdicts()enforces this. - Evaluation is pure and deterministic.
evaluate_quorum()has no I/O and no clock; identical inputs yield identical outputs. This is what makes the benchmark reproducible (themis FED-SPEC-03 statistical methodology). - The event is the truth.
record_quorum_outcome()appends aQUORUM_EVALUATEDevent carrying the full tally and verdicts. TheCorrelationRecord.statusis a materialisation of that event. - No auto-routing. Quorum computes and records. It never advances a workflow, notifies, or triggers downstream action. Humans still attest (Invariant 6).
- Append-only. A re-run that re-evaluates quorum appends a new
QUORUM_EVALUATEDevent; it never edits the prior one (Invariant 2).
Testing
Tests in tests/test_quorum.py.
Policy-evaluation tests (pure function)
test_unanimous_all_match_confirmstest_unanimous_one_no_match_not_reachedtest_majority_three_of_five_confirmstest_majority_tie_not_reachedtest_n_of_m_meets_min_agreeing_confirmstest_weighted_meets_threshold_confirmstest_weighted_below_threshold_not_reachedtest_below_min_participants_indeterminatetest_evaluate_quorum_is_deterministic— same input, 100 runs, identical output
Abstention handling
test_abstain_excluded_when_non_vote— abstentions don't change the denominatortest_abstain_counts_against_when_configuredtest_absent_node_is_abstain_not_no_match—collect_verdictswith missing nodetest_timeout_reason_preserved_on_abstain
Recording
test_record_quorum_outcome_appends_event— lineage grows by exactly onetest_quorum_event_carries_full_tally— details reconstruct the outcometest_confirmed_outcome_sets_status_confirmedtest_not_reached_leaves_status_proposedtest_record_is_append_only— prior lineage byte-identical after recording
Config validation
test_n_of_m_zero_min_agreeing_raisestest_weighted_empty_weights_raisestest_lens_without_quorum_block_parses_quorum_none
Integration
- End-to-end 5-node
run_multi_fusionwith amajoritylens: verify each confirmed correlation carries aQUORUM_EVALUATEDevent whose tally matches the per-node scores; verify P018 (the false-positive trap) isNOT_REACHEDbecause fewer than a majority of nodes scored it above threshold. - Partial-failure: drop one of five nodes; verify that node's
ABSTAINappears in every affected correlation's quorum event and thatFusionRun.status == "partial".
Acceptance
- [ ]
QuorumPolicyenum +QuorumConfigdataclass added tolens_spec.py - [ ]
IdentityFusionConfig.quorum: QuorumConfig | Nonefield added (back-compatible) - [ ]
parse_lens()parses + validates thequorum:block; raisesQuorumConfigError - [ ]
federation/quorum.pywithNodeVote,NodeVerdict,QuorumDecision,QuorumTally,QuorumOutcome - [ ]
evaluate_quorum()pure function with all four policies - [ ]
collect_verdicts()maps per-node scores to verdicts, absence -> ABSTAIN - [ ]
record_quorum_outcome()appendsQUORUM_EVALUATED, transitions status - [ ]
LineageAction.QUORUM_EVALUATEDadded to enum - [ ]
QuorumConfigErroradded tolens/errors.py - [ ] All tests pass
- [ ] One documented example:
examples/quorum_majority_demo.py(5-node majority run)
What themis FED-SPEC-03 measures against this spec
The dissent-fidelity benchmark (FED-SPEC-03.2, ticket THEMIS-2-2) treats SPEC-29 as the quorum half of its gate. The harness will assert:
- Q1 — Quorum outcome is reconstructable. For every correlation produced under a
quorum lens, the
QUORUM_EVALUATEDevent's tally and verdict list reproduce theQuorumOutcomewhen re-evaluated byevaluate_quorum(). (Determinism invariant.) - Q2 — Abstention is not rejection. In a fixture where one node is offline, the
harness asserts that node's verdict is
ABSTAIN(with a reason), neverNO_MATCH, in every correlation it touched. - Q3 — Dissent set is well-defined.
QuorumOutcome.dissenting_node_idsis exactly the set of participants whose vote disagreed with the reached decision — the input SPEC-30 consumes. The harness checks this set is non-empty on the deliberate disagreement fixtures (FED-SPEC-07 §5.2 Multi-INT, 2 of 8 ground-truth entities).
Layering: this spec vs the consensus session (SPEC-36)
This spec is the pure compute layer: evaluate_quorum() is a deterministic
function over a fixed set of verdicts. It has no clock, no deadlines, no state.
The service layer that accumulates contributions over time — deadlines,
required_contributors, minimum_authority_sum, WITHDRAWN/IN_CONFLICT session
states — is SPEC-36 (Consensus Session), implemented at the titan/REST layer
per CL-06. The session calls this spec's evaluation when its quorum-satisfaction
rules say the verdict set is complete. The split keeps the state machine out of
the parallax library (CLAUDE.md Task Pattern boundary) while making the
evaluation independently testable and replayable.
The combiner (SPEC-28) produces a joint_confidence and conflict_indicator
from weighted per-contributor scores; a weighted quorum policy MAY consume the
combiner's contributor weights (composite_rating), and conflict_indicator >
conflict_threshold is a second dissent-emission path recorded by SPEC-30.
Cross-references
- SPEC-30 (Dissent Recording) — consumes
QuorumOutcome.dissenting_node_ids; every dissenting verdict becomes aDissentRecordand aDISSENT_RECORDEDevent. - SPEC-16 (Three-Phase Protocol) — quorum consumes Phase 3 output; privacy guarantees untouched.
- SPEC-24 (Attestation Rationale) — a satisfied quorum still produces a record a human may attest/invalidate/correct; quorum is machine agreement, attestation is human sign-off (Invariant 6).
- SPEC-11 (Correlation Persistence) —
record_quorum_outcome()writes via theCorrelationStore.append_lineage()interface; works against any backend. - SPEC-28 (Multi-Contributor Combination) — combiner math; weights and
conflict_indicatorfeed quorum policies and dissent emission. - SPEC-36 (Consensus Session) — the service-layer session (formerly circulated as SPEC-29 in the CPO-direction series) that drives when evaluation happens.
- themis FED-SPEC-03 §4 (
themis/docs/FED-SPEC-03-FEDERATION-TRUST.md) — the dissent-fidelity benchmark this spec gates.
Open Questions
- Weighted policy with abstaining weighted node. When a node carrying weight in
node_weightsabstains, does its weight drop from the denominator entirely, or doesweight_thresholdstay fixed (making confirmation harder)? Current draft leavesweight_thresholdfixed — i.e. an abstaining heavyweight makes the bar harder to clear. Needs a fixture-backed decision with the themis owner. - Tie-break for even-N MAJORITY. A 2-of-4 / 2-of-4 split is
NOT_REACHEDtoday. Some customers may want a configurable tie-break (e.g. defer to a designated authority node). Deferred —NOT_REACHEDis the safe default until a customer asks. - Re-quorum cadence. When a previously-absent node comes back online in a later
run, do we re-evaluate quorum automatically (appending a second
QUORUM_EVALUATED) or only on explicit re-run? This intersects SPEC-25 (decay/reconfirm scheduling) and SPEC-27 (continuous fusion); resolve jointly there rather than building a trigger here (no auto-routing — Task Pattern). - Per-correlation vs per-pair quorum identity. For N>2 a correlation may be built from several pairwise edges. This draft evaluates quorum per candidate pair and lets UnionFind cluster confirmed edges. Whether a cluster-level quorum (all members agree the cluster is one entity) is additionally required is left to a follow-up once the per-pair version is measured by themis.
Depends on: component.parallax.correlation-persistence, component.parallax.scoring-engine, component.parallax.three-phase-protocol
Realizes: product.fusion
Required by: component.parallax.consensus-session, component.parallax.dissent, component.parallax.wire-message-families