Skip to content

Dissent Recording & Fidelity

Status & scope

  • Stage: DRAFT — ready to implement
  • Module: parallax/ops/fusion/federation/dissent.py (new) + additions to parallax/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.py green — fidelity guarantee proven by test_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:

  1. 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.
  2. Dissent is not retrievable with attribution. Even if a QUORUM_EVALUATED event lists dissenting_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."
  3. 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.

  1. DissentRecord model. 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.
  2. record_dissent() derives dissent records from a QuorumOutcome. Pure function: for every verdict in dissenting_node_ids, build a DissentRecord. Deterministic — same outcome yields the same records.
  3. Dissent is persisted as append-only lineage. A new LineageAction.DISSENT_RECORDED event per dissenting verdict, appended via the existing CorrelationStore.append_lineage(). Never updated, never deleted (Invariant 2). The DissentRecord is serialised into the event details.
  4. Fidelity guarantee + retrieval API. read_dissent(store, correlation_id) returns every DissentRecord ever 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.
  5. Dissent surfaces to human attestation (SPEC-24 linkage). The existing find_dissent() helper (SPEC-24) is extended to also return correlations carrying DISSENT_RECORDED events, 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:

  1. Append-only persistence. persist_dissent() only calls append_lineage(). No code path updates or deletes a DISSENT_RECORDED event (Invariant 2).
  2. Full serialisation. The entire DissentRecord is serialised into the event details; nothing is dropped at write time.
  3. Lossless reconstruction. read_dissent() reconstructs records from the raw lineage; the default (dedupe=False) returns every event with no collapsing.

Invariants

  1. Append-only. Dissent records are never updated or deleted (Invariant 2). A correction is a new event, never an edit.
  2. Zero loss. read_dissent(..., dedupe=False) returns every persisted dissent verdict with full attribution. This is the themis fidelity assertion.
  3. 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).
  4. Pure derivation. record_dissent() and machine_rationale() are pure and deterministic — reproducible for the benchmark.
  5. No PII. DissentRecord carries scores, node ids, and machine rationale only — consistent with the SPEC-16 privacy budget (no raw fields cross the boundary).
  6. Defined relative to a decision. Dissent exists only against a reached quorum decision (CONFIRMED/REJECTED); NOT_REACHED/INDETERMINATE produce no dissent.

Testing

Tests in tests/test_dissent.py.

Derivation (pure)

  • test_record_dissent_one_per_dissenting_node
  • test_record_dissent_empty_when_not_reached
  • test_record_dissent_deterministic — same outcome, identical records
  • test_machine_rationale_cites_score_and_weak_fields

Persistence + fidelity

  • test_persist_dissent_appends_one_event_per_record
  • test_persist_dissent_is_append_only — prior lineage byte-identical after
  • test_read_dissent_zero_loss — persist N, read back N with full attribution
  • test_read_dissent_round_trips_all_fields — every DissentRecord field survives
  • test_read_dissent_dedupe_is_read_only — dedupe view doesn't mutate ledger
  • test_dissent_survives_two_fusion_runs — re-run appends, nothing overwritten

Retrieval

  • test_list_dissenting_correlations_by_node
  • test_list_dissenting_correlations_by_lens
  • test_list_dissenting_correlations_filters_source_machine_vs_human

SPEC-24 bridge

  • test_find_dissent_includes_machine_when_enabled
  • test_find_dissent_excludes_machine_when_disabled
  • test_find_dissent_returns_both_human_and_machine

Integration (the themis scenario)

  • 5-node majority run with a deliberate disagreement fixture (2 nodes scoring a true-match pair below threshold): quorum CONFIRMS, 2 DISSENT_RECORDED events appended, read_dissent returns both with correct node ids and scores, and the pair surfaces in find_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

  • [ ] DissentRecord dataclass + DissentSource enum added to correlation.py
  • [ ] LineageAction.DISSENT_RECORDED added to enum
  • [ ] federation/dissent.py with record_dissent(), machine_rationale(), persist_dissent(), read_dissent(), list_dissenting_correlations()
  • [ ] find_dissent() (SPEC-24) extended with include_machine to 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 a DissentRecord for every node that disagreed with quorum, with actor, vote, and score matching the per-node Phase 3 scores. Zero loss is a hard pass/fail.
  • D2 — Attribution is intact. Each DissentRecord names 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 DissentRecord shape in DISSENT_RECORDED.details against 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 QuorumOutcome and dissenting_node_ids that 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() and read_dissent() go through CorrelationStore; both SQLite and ES satisfy fidelity.
  • SPEC-16 (Three-Phase Protocol)DissentRecord carries scores only, honouring the privacy budget (no raw PII).
  • SPEC-28 (Multi-Contributor Combination)conflict_indicator emission 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

  1. 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 DissentRecord from rationale: str to an added structured field. Resolve when the harness format check (D3) is written.
  2. Read-time dedupe semantics. dedupe=True collapses identical re-recorded dissent across re-runs. Whether identity should include fusion_run_id (treat each run's dissent as distinct) or exclude it (collapse repeats of the same disagreement) needs a fixture-backed decision; default dedupe=False is lossless either way.
  3. 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.
  4. 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 DissentRecord shape. 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