diff --git a/docs/topology-status.md b/docs/topology-status.md index 7edf3d3038..5bf213a9f2 100644 --- a/docs/topology-status.md +++ b/docs/topology-status.md @@ -28,12 +28,12 @@ Generated by `devtools render topology-status`. Reads `docs/plans/topology-targe ### Summary -- **Stable** (no move scoped): 901 +- **Stable** (no move scoped): 902 - **Kernel** (polylogue/ root): 8 - **Primitives** (storage-root): 19 - **TBD** (cell needs explicit assignment): 9 -- **Total declared**: 1072 -- **Realized polylogue/**/*.py**: 1072 files declared +- **Total declared**: 1073 +- **Realized polylogue/**/*.py**: 1073 files declared ### TBD cells (require explicit routing) diff --git a/polylogue/storage/sqlite/archive_tiers/archive.py b/polylogue/storage/sqlite/archive_tiers/archive.py index ba89624592..37e754da96 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive.py +++ b/polylogue/storage/sqlite/archive_tiers/archive.py @@ -3356,17 +3356,61 @@ def apply_raw_membership_classification( f"ambiguous={classification.ambiguous_raw_ids!r}) " f"persisted_session_raw={None if persisted_session is None else str(persisted_session[0])!r}" ) - # No byte-governance refusal here: this branch is only - # reachable when the head raw is a member of THIS - # cohort, and membership rows for a source-keyed raw - # only exist after a governance conversion decided it - # belongs to membership classification -- a still-set - # logical_source_key on raw_sessions is interrupted- - # pass ordering drift (the conversion's key-nulling - # had not committed when the pass died), not evidence - # of live byte-chain governance. Foreign byte heads - # never reach this branch: they yield in the - # chain-governed-head branch above. + # polylogue-miwv: #3211 removed a byte-governance + # refusal here on the theory that this branch is only + # reachable after a real governance conversion (a + # still-set logical_source_key being interrupted-pass + # drift, not live evidence). That premise is false for + # the accepted head specifically: ``_apply_membership_ + # sessions`` (sources/live/batch.py) unconditionally + # injects the current ``raw_revision_heads`` accepted + # raw into the comparison cohort even when it has + # NEVER been through a membership conversion -- the + # #2718 scenario this whole code path exists for + # (a byte-governed head being compared against + # membership-discovered content for the first time). + # Content-prefix growth alone cannot prove the older + # bundle raw supersedes a head that still has live, + # unresolved byte-append evidence hanging off it (a + # quarantined/pending append raw whose + # ``predecessor_source_revision`` chains to the head's + # own ``source_revision``) that this classification + # pass never saw. Only matters when replay is about to + # CHANGE the accepted raw (mirrors #2718's original + # ``accepted_raw_id != existing_raw_id`` guard + # condition) -- a classification that keeps the same + # existing_raw_id as the accepted member (e.g. the + # head's own content already dominates every other + # cohort member) is a no-op re-affirmation, not a + # replacement, regardless of dangling evidence. + # Narrower than #2718's original blanket + # ``logical_source_key IS NOT NULL`` check so #3211's + # own interrupted-pass-drift resumption (no dangling + # append descendant, just a stale un-nulled key) is + # unaffected. + if accepted_raw_id != existing_raw_id: + classified_placeholders = ", ".join("?" for _ in classified_raw_ids) or "NULL" + dangling_append = conn.execute( + f""" + SELECT 1 + FROM raw_sessions AS child + WHERE child.logical_source_key = ? + AND child.raw_id != ? + AND child.raw_id NOT IN ({classified_placeholders}) + AND child.predecessor_source_revision IS NOT NULL + AND child.predecessor_source_revision = ( + SELECT source_revision FROM raw_sessions WHERE raw_id = ? + ) + LIMIT 1 + """, + (logical_source_key, existing_raw_id, *classified_raw_ids, existing_raw_id), + ).fetchone() + if dangling_append is not None: + raise RuntimeError( + "membership replay cannot replace a head with unresolved byte-append " + f"evidence: logical_source_key={logical_source_key!r} " + f"existing_head(raw_id={existing_raw_id!r})" + ) self._conn.execute( "DELETE FROM raw_revision_heads WHERE logical_source_key = ?", (logical_source_key,), diff --git a/tests/unit/sources/test_live_batch_support.py b/tests/unit/sources/test_live_batch_support.py index 9cf662eb21..e871e748f4 100644 --- a/tests/unit/sources/test_live_batch_support.py +++ b/tests/unit/sources/test_live_batch_support.py @@ -3811,6 +3811,31 @@ def test_bundle_replay_respects_unconvertible_single_session_head( succeeds: bool, census_head: bool, ) -> None: + """Pins #2718's fail-closed contract: a bundle raw discovered later must + never silently replace an accepted head that still has live, unresolved + byte-append evidence (the QUARANTINED append raw this test binds), even + when the bundle's own content happens to strictly extend the head's + content (``bundle_texts2``/``bundle_texts3``: content-prefix growth alone + is not proof of provenance). + + polylogue-miwv (2026-07-21): #3211 ("in-cohort head-retire drift fix") + removed ``apply_raw_membership_classification``'s byte-governance refusal + on the mistaken premise that its branch is only reachable after a real + membership-governance conversion -- but ``_apply_membership_sessions`` + unconditionally injects the CURRENT accepted head into the comparison + cohort even when it has never been converted (exactly this test's byte- + governed-head scenario), so the removed guard's absence let the older + bundle's superset content silently move the head (message_count 2->3, + ``accepted_raw_id`` changed) for ``bundle_texts2``/``bundle_texts3``. + This was not caused by, and is unrelated to, the messages_fts_identity + UNIQUE(block_id) ledger work landing the same day (polylogue-miwv's + other commits) -- confirmed by reproducing this exact failure on the + commit immediately preceding messages_fts_identity's introduction. + Restored as a narrower guard (refuses only when replay is about to change + the accepted raw AND a live raw_sessions row still chains a + ``predecessor_source_revision`` off the existing head) so #3211's own + interrupted-pass-drift resumption keeps working. + """ root = tmp_path / "sessions" root.mkdir() current = root / "current.jsonl"