Dissent Recording & Fidelity
Status & scope
- Stage: DRAFT — ready to implement
- Module:
parallax/ops/fusion/federation/dissent.py(new) + additions toparallax/ops/fusion/models/correlation.py - Depends on: correlation-persistence, attestation-rationale, quorum, existing
LineageAction+CorrelationStore - 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_dissent.pygreen — fidelity guarantee proven bytest_read_dissent_zero_loss
Problem
When SPEC-29 quorum reaches a decision, some nodes disagreed with it. A majority
policy that CONFIRMS a correlation with 3 of 5 nodes voting MATCH leaves 2 nodes
that voted NO_MATCH. Today those two verdicts vanish: the correlation carries a
single confidence and status, and the dissenting opinions are weighted away by
the very aggregation that produced the decision.
This is exactly the failure themis FED-SPEC-03.2 is built to catch. Its defensive reframing: "Not 'preserved dissent' — attributable disagreement as a first-class output." What centralized systems do is weighting, smoothing, silent merging. What the benchmark requires is that when contributors disagree, both perspectives survive in the audit chain with attribution, retrievable post-fusion with zero loss.
Three failure modes without this spec:
- Machine dissent is destroyed by aggregation. A node that scored a pair below threshold has its verdict averaged/voted away. The disagreement is real signal (it may be the node that is right — see the P018 false-positive trap) and it is gone the moment quorum records a decision.
- Dissent is not retrievable with attribution. Even if a
QUORUM_EVALUATEDevent listsdissenting_node_ids(SPEC-29), there is no first-class record that says which node, with what score, under what lens version, for what reason dissented — and no query surface to pull "every dissent on this correlation" or "every correlation where node B dissented." - Machine dissent and human dissent are unrelated. SPEC-24 captures human dissent (two analysts disagreeing, both rationales preserved). SPEC-29 produces machine dissent (nodes disagreeing with quorum). Nothing links them, so a human attesting a quorum-confirmed correlation never sees that 2 nodes disagreed.
SPEC-29 defines when dissent exists (QuorumOutcome.dissenting_node_ids). SPEC-30
defines how it is recorded, guaranteed, and retrieved — the "dissent fidelity"
contract themis measures.
Decision
Make machine dissent a first-class, append-only, zero-loss record.
DissentRecordmodel. A frozen dataclass capturing one dissenting node's verdict at the moment of a quorum decision: node id, the vote it cast, its score, per-field scores, a rationale (machine-generated for nodes — e.g. "scored 0.41 < 0.70; DOB disagreed"), lens version, and timestamp. Pure data, no PII.record_dissent()derives dissent records from aQuorumOutcome. Pure function: for every verdict indissenting_node_ids, build aDissentRecord. Deterministic — same outcome yields the same records.- Dissent is persisted as append-only lineage. A new
LineageAction.DISSENT_RECORDEDevent per dissenting verdict, appended via the existingCorrelationStore.append_lineage(). Never updated, never deleted (Invariant 2). TheDissentRecordis serialised into the eventdetails. - Fidelity guarantee + retrieval API.
read_dissent(store, correlation_id)returns everyDissentRecordever recorded for a correlation, reconstructed from the ledger with zero loss.list_dissenting_correlations(store, node_id=...)answers "every correlation where node B dissented." This is the surface themis asserts against. - Dissent surfaces to human attestation (SPEC-24 linkage). The existing
find_dissent()helper (SPEC-24) is extended to also return correlations carryingDISSENT_RECORDEDevents, so the human attestation/inbox path sees machine dissent alongside human dissent. Dissent is recorded, never auto-resolved — a human decides what to do with it (Invariant 6).
Architecture
SPEC-29 evaluate_quorum() -> QuorumOutcome (decision + dissenting_node_ids)
│
▼
record_dissent(outcome) ── PURE: QuorumOutcome -> tuple[DissentRecord]
│
▼
persist_dissent(store, cr_id, records)
│ appends one DISSENT_RECORDED LineageEntry per record (append-only)
▼
┌────────────────────────────┬─────────────────────────────┐
│ read_dissent(cr_id) │ list_dissenting_correlations │ ◄── themis
│ -> all DissentRecords │ (node_id) -> [cr_id] │ retrieval
└────────────────────────────┴─────────────────────────────┘
│
▼
find_dissent() (SPEC-24, extended) ── human attestation/inbox sees BOTH
machine dissent (this spec) and
human dissent (SPEC-24)
Pure-Python throughout. Recording and reading go through the CorrelationStore
abstract interface (SPEC-22), so SQLite (standalone) and titan→ES (platform) both
satisfy the fidelity guarantee with the same code.
Public API
Dissent model (new — models/correlation.py additions)
# parallax/ops/fusion/models/correlation.py (additions)
class DissentSource(str, Enum):
MACHINE = "machine" # a federated node disagreed with quorum (SPEC-29)
HUMAN = "human" # an analyst disagreed (SPEC-24 attestation dissent)
@dataclass(frozen=True)
class DissentRecord:
"""One attributable dissenting verdict against a reached decision.
Frozen, append-only, no PII. For MACHINE dissent the `actor` is a
node_id and `rationale` is machine-generated from the score context
("scored 0.41 < 0.70 confirmation; dob_match 0.10"). For HUMAN dissent
(SPEC-24 bridge) the `actor` is the analyst and `rationale` is their note.
"""
correlation_id: str
source: DissentSource
actor: str # node_id (machine) or analyst (human)
dissented_against: str # the QuorumDecision value dissented from
vote: str # NodeVote value cast by the dissenter
score: float = 0.0
per_field_scores: tuple[tuple[str, float], ...] = ()
rationale: str = ""
lens_id: str = ""
lens_version: str = ""
quorum_policy: str = "" # policy under which dissent arose
fusion_run_id: str = ""
timestamp: str = ""
New LineageAction value
# extends LineageAction enum (correlation.py)
class LineageAction(str, Enum):
# ... existing, incl. QUORUM_EVALUATED (SPEC-29) ...
DISSENT_RECORDED = "dissent_recorded" # NEW — SPEC-30
Deriving dissent — pure function
# parallax/ops/fusion/federation/dissent.py (new module)
def record_dissent(
correlation_id: str,
outcome: QuorumOutcome,
fusion_run_id: str = "",
lens_id: str = "",
timestamp: str = "",
) -> tuple[DissentRecord, ...]:
"""Derive DissentRecords from a QuorumOutcome. PURE.
- One DissentRecord per node in outcome.dissenting_node_ids.
- Deterministic: same outcome -> same records (ordered by node_id).
- rationale is machine-generated from the verdict's score + per_field_scores
and the confirmation threshold (no LLM, no I/O).
- Returns () when decision is NOT_REACHED / INDETERMINATE (no decision =>
no dissent is defined; matches SPEC-29 semantics).
"""
def machine_rationale(verdict: NodeVerdict, threshold: float) -> str:
"""Build a deterministic human-readable rationale for a dissenting node.
e.g. "node firm_b voted no_match: score 0.41 < 0.70; weakest fields
dob 0.10, postcode 0.33". Pure string construction from the verdict.
"""
Persisting + retrieving — the fidelity surface
# parallax/ops/fusion/federation/dissent.py
def persist_dissent(
store: CorrelationStore,
correlation_id: str,
records: tuple[DissentRecord, ...],
) -> int:
"""Append one DISSENT_RECORDED LineageEntry per DissentRecord.
Append-only (Invariant 2): uses store.append_lineage(); never updates or
deletes. The DissentRecord is serialised into LineageEntry.details so it
is reconstructable from the ledger alone. Returns count appended.
Idempotency is NOT assumed — re-running a fusion appends new dissent
events; de-duplication, if wanted, is a read-time concern (see
read_dissent dedupe note), never a write-time mutation.
"""
def read_dissent(
store: CorrelationStore,
correlation_id: str,
dedupe: bool = False,
) -> list[DissentRecord]:
"""Return every DissentRecord ever recorded for a correlation.
Reconstructed from DISSENT_RECORDED lineage events via
store.read_lineage(). ZERO LOSS: every dissenting verdict that was
persisted is returned. With dedupe=True, collapse records identical in
(actor, vote, lens_version, score) keeping the earliest timestamp — a
read-time view only; the ledger is never mutated.
"""
def list_dissenting_correlations(
store: CorrelationStore,
node_id: str | None = None,
lens_id: str | None = None,
source: DissentSource | None = None,
limit: int = 100,
) -> list[str]:
"""Return correlation_ids carrying DISSENT_RECORDED events matching the
filters. Answers "every correlation where node B dissented" (node_id) and
feeds the dissent inbox (#17). Scans via store.list_correlations() +
read_lineage(); production ES backend may index DISSENT_RECORDED for speed.
"""
SPEC-24 bridge — human attestation sees machine dissent
# parallax/ops/fusion/correlation/correlation.py (extend SPEC-24 helper)
def find_dissent(
store: CorrelationStore,
lens_id: str | None = None,
include_machine: bool = True, # NEW — SPEC-30
) -> list[CorrelationRecord]:
"""Return correlations whose lineage contains disagreement:
(a) multiple distinct human actors with opposing ATTESTED/INVALIDATED
events, or an ATTESTATION_CORRECTED event (SPEC-24), OR
(b) when include_machine=True, any DISSENT_RECORDED event (SPEC-30).
Both machine and human dissent surface to the same attestation/inbox path.
Dissent is surfaced, never auto-resolved — a human decides (Invariant 6).
"""
Dissent fidelity guarantee (the contract themis measures)
Every dissenting verdict that was persisted is retrievable post-fusion with zero loss, attributed to its source.
Formally, for any correlation c and any sequence of fusion runs:
read_dissent(store, c) ⊇ { record_dissent(c, outcome_i) for every quorum
outcome_i recorded on c }
with attribution (actor, vote, score, lens_version) preserved exactly.
Guaranteed by three properties:
- Append-only persistence.
persist_dissent()only callsappend_lineage(). No code path updates or deletes aDISSENT_RECORDEDevent (Invariant 2). - Full serialisation. The entire
DissentRecordis serialised into the eventdetails; nothing is dropped at write time. - Lossless reconstruction.
read_dissent()reconstructs records from the raw lineage; the default (dedupe=False) returns every event with no collapsing.
Invariants
- Append-only. Dissent records are never updated or deleted (Invariant 2). A correction is a new event, never an edit.
- Zero loss.
read_dissent(..., dedupe=False)returns every persisted dissent verdict with full attribution. This is the themis fidelity assertion. - Dissent is recorded, never auto-resolved. SPEC-30 records and retrieves; it never decides who was right or mutates a quorum decision. A human adjudicates (Invariant 6 — AI assists, humans attest).
- Pure derivation.
record_dissent()andmachine_rationale()are pure and deterministic — reproducible for the benchmark. - No PII.
DissentRecordcarries scores, node ids, and machine rationale only — consistent with the SPEC-16 privacy budget (no raw fields cross the boundary). - Defined relative to a decision. Dissent exists only against a reached quorum
decision (CONFIRMED/REJECTED);
NOT_REACHED/INDETERMINATEproduce no dissent.
Testing
Tests in tests/test_dissent.py.
Derivation (pure)
test_record_dissent_one_per_dissenting_nodetest_record_dissent_empty_when_not_reachedtest_record_dissent_deterministic— same outcome, identical recordstest_machine_rationale_cites_score_and_weak_fields
Persistence + fidelity
test_persist_dissent_appends_one_event_per_recordtest_persist_dissent_is_append_only— prior lineage byte-identical aftertest_read_dissent_zero_loss— persist N, read back N with full attributiontest_read_dissent_round_trips_all_fields— every DissentRecord field survivestest_read_dissent_dedupe_is_read_only— dedupe view doesn't mutate ledgertest_dissent_survives_two_fusion_runs— re-run appends, nothing overwritten
Retrieval
test_list_dissenting_correlations_by_nodetest_list_dissenting_correlations_by_lenstest_list_dissenting_correlations_filters_source_machine_vs_human
SPEC-24 bridge
test_find_dissent_includes_machine_when_enabledtest_find_dissent_excludes_machine_when_disabledtest_find_dissent_returns_both_human_and_machine
Integration (the themis scenario)
- 5-node
majorityrun with a deliberate disagreement fixture (2 nodes scoring a true-match pair below threshold): quorum CONFIRMS, 2 DISSENT_RECORDED events appended,read_dissentreturns both with correct node ids and scores, and the pair surfaces infind_dissent()for human attestation. Mirrors FED-SPEC-07 §5.2 Multi-INT (2 of 8 ground-truth entities). - P018 mirror: where the dissenting node is the correct one (two John Smiths), verify the dissent is preserved verbatim so a human can see the node that flagged the false match.
Acceptance
- [ ]
DissentRecorddataclass +DissentSourceenum added tocorrelation.py - [ ]
LineageAction.DISSENT_RECORDEDadded to enum - [ ]
federation/dissent.pywithrecord_dissent(),machine_rationale(),persist_dissent(),read_dissent(),list_dissenting_correlations() - [ ]
find_dissent()(SPEC-24) extended withinclude_machineto surface machine dissent - [ ] Fidelity guarantee proven by
test_read_dissent_zero_loss+test_dissent_survives_two_fusion_runs - [ ] All tests pass
- [ ] Documented example:
examples/dissent_fidelity_demo.py(5-node majority, 2 dissenters, retrieval + human surfacing)
What themis FED-SPEC-03 measures against this spec
FED-SPEC-03.2 (THEMIS-2-2) measures dissent fidelity directly against SPEC-30:
- D1 — Both perspectives survive. On the deliberate-disagreement fixture, the
harness asserts
read_dissent()returns aDissentRecordfor every node that disagreed with quorum, withactor,vote, andscorematching the per-node Phase 3 scores. Zero loss is a hard pass/fail. - D2 — Attribution is intact. Each
DissentRecordnames the dissenting node and the lens version under which it dissented. The harness checks attribution is never anonymised or merged. - D3 — Format conformance. THEMIS-2-2 acceptance: "MachineDissent record format
conforms to SPEC-30." The harness validates the serialised
DissentRecordshape inDISSENT_RECORDED.detailsagainst the dataclass above. - D4 — Not silently weighted away. The harness confirms the quorum decision (SPEC-29) does not delete the minority verdicts — confirmed by D1 retrieval against the same fixture where naive weighting would have dropped them.
MachineDissent compatibility (CPO-direction draft alignment)
The CPO-direction series circulated a MachineDissent schema (now archived:
_archive_v1/SPEC-30-MACHINE-DISSENT.md). themis FED-SPEC-03's format check
("MachineDissent record format conforms to SPEC-30") is satisfied by
DissentRecord with source=MACHINE. Field mapping:
| MachineDissent (draft) | DissentRecord (this spec) |
|---|---|
dissent_id |
derived — LineageEntry event id carries identity |
session_id |
fusion_run_id (standalone) / SPEC-36 session id (platform) |
lens_id, lens_version |
lens_id, lens_version |
joint_confidence, conflict_indicator, conflict_threshold |
metadata of the QUORUM_EVALUATED event (SPEC-29) referenced by the dissent |
contributions[] (per-contributor breakdown) |
one DissentRecord per dissenting node; per-field detail in per_field_scores |
pair_score_min/max/spread |
derivable from the recorded verdict set |
classification, signature, signer_key_id |
platform envelope concerns (CL-08/CL-09) added at the service layer, not stored in the compute-layer record |
resolution |
never mutated onto the record — a human resolution is a NEW lineage event (SPEC-24 ATTESTATION_CORRECTED / attestation), Invariant 2 |
Second emission path. Besides quorum-vote dissent (dissenting_node_ids),
dissent is also emitted when the combiner's conflict_indicator (SPEC-28)
exceeds the session's conflict_threshold under conflict_policy: flag or
split (SPEC-36). Both paths produce the same DissentRecord shape; the
trigger is recorded in rationale. A session RATIFIED by human override after
IN_CONFLICT keeps its dissent records — they are history, not state.
Cross-references
- SPEC-29 (Federated Quorum) — produces the
QuorumOutcomeanddissenting_node_idsthat SPEC-30 records. SPEC-30 is meaningless without it. - SPEC-24 (Attestation Rationale) — human dissent;
find_dissent()is the shared surface. SPEC-30 reuses the append-only-lineage and rationale patterns SPEC-24 established. - SPEC-11 / SPEC-22 (Correlation Persistence / Store) —
persist_dissent()andread_dissent()go throughCorrelationStore; both SQLite and ES satisfy fidelity. - SPEC-16 (Three-Phase Protocol) —
DissentRecordcarries scores only, honouring the privacy budget (no raw PII). - SPEC-28 (Multi-Contributor Combination) —
conflict_indicatoremission path. - SPEC-36 (Consensus Session) —
conflict_policy(suppress/flag/split) semantics. - themis FED-SPEC-03 §4 / THEMIS-2-2 (
themis/docs/FED-SPEC-03-FEDERATION-TRUST.md, themis #7) — the dissent-fidelity benchmark this spec satisfies.
Open Questions
- Machine rationale richness. The draft generates rationale deterministically from
score context ("0.41 < 0.70; weak fields dob, postcode"). Whether themis wants a
structured rationale (machine-readable field-level disagreement) in addition to the
string is open — would change
DissentRecordfromrationale: strto an added structured field. Resolve when the harness format check (D3) is written. - Read-time dedupe semantics.
dedupe=Truecollapses identical re-recorded dissent across re-runs. Whether identity should includefusion_run_id(treat each run's dissent as distinct) or exclude it (collapse repeats of the same disagreement) needs a fixture-backed decision; defaultdedupe=Falseis lossless either way. - Dissent decay. Should an old machine dissent decay/expire like correlation confidence (SPEC-25)? Current position: no — dissent is an immutable historical fact, not a live signal. Flagged in case a customer wants "active dissent" views.
- Cluster-level dissent. Tied to SPEC-29 Open Question 4 — if cluster-level quorum
lands, dissent against a cluster identity (vs a pairwise edge) needs its own
DissentRecordshape. Deferred until per-pair dissent is measured by themis.
Depends on: component.parallax.attestation-rationale, component.parallax.correlation-persistence, component.parallax.quorum
Realizes: product.fusion
Required by: component.parallax.consensus-mission-service, component.parallax.consensus-session, component.parallax.fusion-adi-integration, component.parallax.wire-message-families