Attestation Rationale + Correction
Status & scope
- Stage: DRAFT
- Module:
parallax/ops/fusion/models/correlation.py+parallax/ops/fusion/correlation.py - Builds on: existing
LineageAction+CorrelationStatusenums - Part of: #18 Full Fusion Cycle · Umbrella #6 · Supports #7 (dissent recipe)
- Milestone: M4 (OSS Substrate)
Problem
Attestation captures what an analyst decided, not why. The LineageEvent carries the action (ATTESTED, INVALIDATED, RECONFIRMED), actor, timestamp, and scoring context — but no field for the analyst's reasoning.
Three failure modes this creates:
1. No reconstructable reasoning. Six months later, a supervisor reviewing a contested attestation has no record of why the analyst decided the way they did. "Because the evidence supported it" is not a defence.
2. No dissent capture. Two analysts can disagree (the dissent recipe in #7), but without rationale there's nothing to compare — the ledger records two opposing actions with identical metadata.
3. No correction path. Analysts make mistakes. Today the only recovery is to INVALIDATE + re-ATTEST, which loses the causal link to the original error. Append-only invariant must be preserved without hiding what was corrected.
Regulatory context: GDPR Article 22 (right to explanation for automated decisions), EU AI Act Article 12 (automatic recording of events), FCA Consumer Duty (operator-level audit of decision rationale) — all require a human-readable rationale to be recoverable per decision.
Decision
Three changes:
-
Add
rationale: strtoLineageEvent. Required forATTESTEDandINVALIDATED. Optional forCREATED,RECORD_ADDED,RECORD_REMOVED,DECAYED,RECONFIRMED,SCORE_UPDATED(these are system-generated or derivable). -
Add
ATTESTATION_CORRECTEDtoLineageActionenum. Append-only correction — points at the superseded event viasupersedes_event_id. Never rewrites history; the prior event remains in the lineage and remains queryable. -
Enforce at the write path.
apply_attestation()andinvalidate_correlation()raise ifrationaleis empty or whitespace-only. System callers (decay, reconfirm triggered by automated logic) remain unaffected.
Architecture
Event flow — attest, dissent, correct
t=0: CREATED — system, rationale=""
t=1: ATTESTED — Analyst A, "DOB + postcode agree; name Jaro-Winkler
0.94; no conflicting records in last 5y."
t=2: ATTESTED — Analyst B, "Disagree — same soundex but DOB off by
12y; likely different people. See note on related
correlation CR-xyz."
↑ Dissent: two ATTESTED events, both preserved,
both carry full rationale.
t=3: INVALIDATED — Supervisor C, "Adjudicated dissent between A and
B; B's evidence stronger. Setting to REJECTED."
t=4: ATTESTATION_ — Analyst A, "Withdrawing my t=1 attestation after
CORRECTED reviewing B's note. New review pending.",
supersedes_event_id=<t=1 event>
No event is ever rewritten. ATTESTATION_CORRECTED is the mechanism by which an analyst acknowledges they got it wrong, without pretending they didn't.
Public API
Model change
# parallax/ops/fusion/models/correlation.py (existing file, additions)
class LineageAction(str, Enum):
CREATED = "created"
RECORD_ADDED = "record_added"
RECORD_REMOVED = "record_removed"
SCORE_UPDATED = "score_updated"
ATTESTED = "attested"
INVALIDATED = "invalidated"
DECAYED = "decayed"
RECONFIRMED = "reconfirmed"
ATTESTATION_CORRECTED = "attestation_corrected" # NEW
@dataclass(frozen=True)
class LineageEvent:
# ... existing fields ...
rationale: str = "" # NEW — free-text analyst reasoning
supersedes_event_id: str | None = None # NEW — only set for ATTESTATION_CORRECTED
Write-path enforcement
# parallax/ops/fusion/correlation.py (existing, additions)
# Actions that require human rationale when the actor is a human.
_RATIONALE_REQUIRED_ACTIONS = {
LineageAction.ATTESTED,
LineageAction.INVALIDATED,
LineageAction.ATTESTATION_CORRECTED,
}
def apply_attestation(
cr: CorrelationRecord,
actor: str,
rationale: str,
fusion_run_id: str,
) -> CorrelationRecord:
"""Apply a human ATTESTED event. Requires non-empty rationale."""
if not rationale or not rationale.strip():
raise RationaleRequiredError(
f"Attestation requires non-empty rationale. Action: ATTESTED, actor: {actor}"
)
# ... existing logic ...
def invalidate_correlation(
cr: CorrelationRecord,
actor: str,
rationale: str,
fusion_run_id: str,
) -> CorrelationRecord:
"""Apply a human INVALIDATED event. Requires non-empty rationale."""
# same rationale enforcement ...
def correct_attestation(
cr: CorrelationRecord,
actor: str,
rationale: str,
supersedes_event_id: str,
fusion_run_id: str,
) -> CorrelationRecord:
"""Append an ATTESTATION_CORRECTED event pointing at a prior event.
Validates that supersedes_event_id is a real event in cr.source_lineage.
Does NOT modify the prior event. The prior event remains queryable;
consumers can surface both the original and the correction.
"""
Dissent query (convenience)
# parallax/ops/fusion/correlation.py (new helper)
def find_dissent(
store: CorrelationStore,
lens_id: str | None = None,
) -> list[CorrelationRecord]:
"""Return correlations whose lineage contains disagreement:
either (a) multiple distinct actors with opposing ATTESTED /
INVALIDATED events, or (b) an ATTESTATION_CORRECTED event.
Used by the UX dissent inbox (#17) and the dissent recipe in #7.
"""
Invariants
- Rationale is not nullable once required. The write path rejects empty / whitespace-only strings for
ATTESTED,INVALIDATED,ATTESTATION_CORRECTED. - Prior events remain queryable.
ATTESTATION_CORRECTEDnever deletes or rewrites the event it supersedes. - Supersedes pointer is validated. If the referenced event does not exist in the correlation's lineage,
correct_attestation()raises. - System-actor events unaffected.
apply_decay(), automatic reconfirmation, and system-generated events retainrationale=""with no enforcement.
Testing
Tests in tests/test_attestation_rationale.py.
Required-rationale tests
test_attest_without_rationale_raisestest_attest_with_whitespace_only_rationale_raisestest_invalidate_without_rationale_raisestest_decay_without_rationale_ok— system action, no enforcement
Correction-path tests
test_attestation_corrected_preserves_prior_eventtest_attestation_corrected_with_bad_supersedes_raisestest_correlation_lineage_returns_both_events— both original and correction returned
Dissent-query tests
test_find_dissent_two_opposing_analyststest_find_dissent_attestation_correctedtest_find_dissent_no_conflict_returns_empty
Integration
- End-to-end: create CR → Analyst A attests → Analyst B invalidates → Supervisor C adjudicates → Analyst A corrects. Lineage has 5 events, all queryable, no mutations.
Acceptance
- [ ]
LineageEvent.rationaleandLineageEvent.supersedes_event_idfields added - [ ]
LineageAction.ATTESTATION_CORRECTEDadded to enum - [ ]
RationaleRequiredErrorexception class added - [ ]
apply_attestation/invalidate_correlationsignatures updated to requirerationale - [ ]
correct_attestation()function added - [ ]
find_dissent()helper added - [ ] All tests pass
- [ ] One documented example:
examples/dissent_workflow_demo.py
Rollback plan
Schema-compatible — rationale defaults to "", supersedes_event_id defaults to None. Old records round-trip unchanged.
If full rollback needed:
1. Remove enforcement in apply_attestation / invalidate_correlation (two if statements).
2. Leave the fields on LineageEvent — no consumer breaks from their presence.
3. ATTESTATION_CORRECTED events (if any exist) become no-ops — consumers that don't know the action treat it as unknown.
The append-only invariant means even partial rollback cannot corrupt state. At worst, enforcement is disabled and operators can write empty rationales again. No data loss.
Migration notes
Historical events predating this spec have rationale="" and no supersedes_event_id. UX consumers (#17) should handle empty rationale gracefully — render "(no rationale recorded)" rather than crashing. Proposed at review time: backfill campaign is NOT authorised — we do not invent rationales retroactively. The empty field is the truth for pre-spec attestations.
Out of scope (tracked separately)
- UX surfaces for entering rationale + viewing dissent → #17
- Cross-analyst delegation / handoff workflows → future
- LLM-assisted rationale drafting (analyst starts a note, LLM suggests completions based on evidence) → post-90-day
- Rationale classification / tagging for analytics ("wrong field match", "insufficient evidence", "source unreliable") → post-90-day, needs a taxonomy we don't have
Depends on: component.parallax.local-persistence-adapter
Realizes: product.fusion
Required by: component.parallax.continuous-fusion, component.parallax.decay-scheduler, component.parallax.dissent, component.parallax.local-quickstart, component.parallax.wire-message-families