diff --git a/polylogue/storage/raw_reconciler.py b/polylogue/storage/raw_reconciler.py index a322717d8c..2e14de817c 100644 --- a/polylogue/storage/raw_reconciler.py +++ b/polylogue/storage/raw_reconciler.py @@ -649,13 +649,41 @@ def _strategy_overrides( def _record_judgment_candidate(config: Config, item: RawAuthorityFrontierItem, *, now_ms: int) -> tuple[str, bool]: - """Persist the conflict as a non-authoritative candidate for operator judgment.""" + """Persist the conflict as a non-authoritative candidate for operator judgment. + + polylogue-rjtv: the assertion id is derived from ``item.plan_id``, which is + itself derived from a fresh evidence digest every census cycle -- a + census cycle that re-encounters the *same* unresolved conflict (same + ``raw_id``/``logical_source_key``) before an operator has judged it would + otherwise mint a brand-new candidate each time, leaving prior cycles' + still-pending duplicates to accumulate forever (found live 2026-07-27: 24 + candidates in ``judge --list`` for what was actually 6 real conflicts). + Look up an existing still-``candidate`` request for the same conflict + identity first and refresh it in place instead of minting a new one. This + only dedupes pending-vs-pending; an already accepted/rejected/deferred + assertion is untouched, so a fresh judgment can still be requested if the + same conflict resurfaces after a prior disposition. + """ from polylogue.core.enums import AssertionKind, AssertionStatus, AssertionVisibility from polylogue.storage.sqlite.archive_tiers.user_write import read_assertion_envelope, upsert_assertion - assertion_id = f"judgment:{_digest(['raw-authority-frontier', item.plan_id])}" root = _archive_root(config) with closing(sqlite3.connect(root / "user.db")) as conn, conn: + pending_row = conn.execute( + """ + SELECT assertion_id FROM assertions + WHERE kind = 'judgment' AND status = 'candidate' + AND json_extract(value_json, '$.raw_id') = ? + AND json_extract(value_json, '$.logical_source_key') = ? + LIMIT 1 + """, + (item.raw_id, item.logical_source_key), + ).fetchone() + assertion_id = ( + str(pending_row[0]) + if pending_row is not None + else f"judgment:{_digest(['raw-authority-frontier', item.plan_id])}" + ) existing = read_assertion_envelope(conn, assertion_id) if existing is not None and existing.status is not AssertionStatus.CANDIDATE: return existing.assertion_id, False diff --git a/tests/unit/storage/test_browser_capture_origin_repair.py b/tests/unit/storage/test_browser_capture_origin_repair.py index a5247fa985..b39a8ee732 100644 --- a/tests/unit/storage/test_browser_capture_origin_repair.py +++ b/tests/unit/storage/test_browser_capture_origin_repair.py @@ -17,7 +17,9 @@ from polylogue.storage.raw_authority import resolve_raw_authority_blocker from polylogue.storage.raw_reconciler import ( RawAuthorityActuator, + RawAuthorityFrontierItem, RawAuthorityFrontierState, + _record_judgment_candidate, apply_raw_authority_frontier, inspect_raw_authority_frontier, ) @@ -774,6 +776,49 @@ def test_unified_frontier_conflict_requires_typed_judgment_then_resumes_same_evi ).fetchone() == (0,) +def test_repeat_census_of_same_pending_conflict_reuses_one_judgment_candidate(tmp_path: Path) -> None: + """polylogue-rjtv: two census cycles hitting the same still-unjudged + conflict (same raw_id/logical_source_key, different plan_id/evidence - + exactly what a fresh census run produces each time it re-derives + evidence) must not mint a second pending judgment candidate. Found live + 2026-07-27: 24 candidates in ``judge --list`` for what was actually 6 + real conflicts, because assertion_id was derived from the ephemeral + plan_id instead of the stable conflict identity.""" + initialize_active_archive_root(tmp_path) + + def _item(plan_suffix: str) -> RawAuthorityFrontierItem: + return RawAuthorityFrontierItem( + state=RawAuthorityFrontierState.CONFLICTING_AUTHORITY_NEEDS_JUDGMENT, + actuator=RawAuthorityActuator.REQUEST_JUDGMENT, + raw_id="raw-rjtv-shared", + logical_source_key="unknown:rjtv-conflict", + session_id="chatgpt-export:rjtv-conflict", + reason="byte-proven browser rekey requires no retained membership census", + evidence_digest=f"digest-{plan_suffix}", + input_raw_ids=("raw-rjtv-shared",), + source_preconditions={}, + index_preconditions={}, + strategy_witness={"kind": "browser_conflict"}, + plan_id=f"raw-authority-frontier:{plan_suffix}", + ) + + config = _config(tmp_path) + first_id, _ = _record_judgment_candidate(config, _item("cycle-one"), now_ms=1000) + second_id, _ = _record_judgment_candidate(config, _item("cycle-two"), now_ms=2000) + + assert second_id == first_id + with sqlite3.connect(tmp_path / "user.db") as user: + user.row_factory = sqlite3.Row + rows = user.execute( + "SELECT assertion_id, value_json FROM assertions WHERE kind = 'judgment' AND status = 'candidate'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["assertion_id"] == first_id + # The single surviving row reflects the LATEST cycle's plan, not stale + # evidence from the first. + assert json.loads(rows[0]["value_json"])["plan_id"] == "raw-authority-frontier:cycle-two" + + def test_inspect_conflicts_membership_precondition_evidence(tmp_path: Path) -> None: raw_id = _seed_byte_proven_browser_head_without_native_id(tmp_path) with sqlite3.connect(tmp_path / "source.db") as source: