From d751e794563b5ecb5e857bedacd10e484b1d4ebc Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 23:46:09 +0200 Subject: [PATCH] fix(storage): an absent source tier refuses nothing A freshly created archive root has no source.db, so the frontier projection reported the tier unavailable under both the broken-head and cursor checks and those reasons surfaced as an unattributed refusal that blocked the whole raw-materialization pass. Absence means nothing has been acquired; damage still refuses. The blob-reference-closure test seeded the explained shape (ref_count 0) while asserting the blocking one; ref_count is the discriminator. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- polylogue/storage/raw_retention.py | 5 +++++ .../maintenance/test_archive_verification.py | 10 +++++++++- tests/unit/storage/test_raw_retention.py | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/polylogue/storage/raw_retention.py b/polylogue/storage/raw_retention.py index ddad3761a..23d0e0c70 100644 --- a/polylogue/storage/raw_retention.py +++ b/polylogue/storage/raw_retention.py @@ -1306,6 +1306,11 @@ def raw_frontier_blocked_source_paths( ) -> RawFrontierBlockedPaths: """Attribute the frontier proof's refusals to exact source paths.""" + if not (archive_root / "source.db").is_file(): + # No source tier means nothing has been acquired yet, so there is + # nothing to select and nothing to refuse. An unreadable tier still + # refuses below: absence and damage are different states. + return RawFrontierBlockedPaths(frozenset(), None) projection = raw_frontier_integrity_projection( archive_root, raw_materialization_readiness, diff --git a/tests/unit/maintenance/test_archive_verification.py b/tests/unit/maintenance/test_archive_verification.py index 2bf79deff..866832b0e 100644 --- a/tests/unit/maintenance/test_archive_verification.py +++ b/tests/unit/maintenance/test_archive_verification.py @@ -1419,12 +1419,20 @@ def test_blob_refs_liveness_passes_on_coherent_archive(tmp_path: Path) -> None: def test_blob_reference_closure_rejects_acquired_attachment_without_ref(tmp_path: Path) -> None: + """An acquired attachment whose refs vanished is archive debt. + + ``ref_count`` is the discriminator: a non-zero count with no surviving + ``attachment_refs`` row means the sweep lost edges it once had, while a + zero count is an attachment that never had one. + + Anti-vacuity: seeding ``ref_count`` 0 instead describes the explained + shape and the check stays clean.""" _seed_coherent_archive(tmp_path) conn = _connect(tmp_path / "index.db") try: conn.execute( "INSERT INTO attachments (attachment_id, byte_count, blob_hash, acquisition_status, ref_count) " - "VALUES ('orphan-acquired', 1, ?, 'acquired', 0)", + "VALUES ('orphan-acquired', 1, ?, 'acquired', 1)", (b"a" * 32,), ) conn.commit() diff --git a/tests/unit/storage/test_raw_retention.py b/tests/unit/storage/test_raw_retention.py index 1af69b475..a9d4a58c0 100644 --- a/tests/unit/storage/test_raw_retention.py +++ b/tests/unit/storage/test_raw_retention.py @@ -2577,6 +2577,23 @@ def test_deferred_cursor_never_blocks_source_selection(tmp_path: Path) -> None: assert raw_frontier_source_selection_block_reason(tmp_path) is None +def test_absent_source_tier_refuses_nothing(tmp_path: Path) -> None: + """An archive root with no source tier has nothing to select or refuse. + + Anti-vacuity: dropping the absence branch makes the projection report + the tier unavailable under both the broken-head and cursor checks, and + those reasons surface as an unattributed refusal that blocks the whole + materialization pass on a freshly created archive. + """ + archive_root = tmp_path / "archive" + archive_root.mkdir() + + blocked = raw_retention_mod.raw_frontier_blocked_source_paths(archive_root, {}) + + assert blocked.unattributed_reason is None + assert blocked.source_paths == frozenset() + + def test_blocked_source_paths_refuse_violations_and_admit_authority_gaps(tmp_path: Path) -> None: """A violation names the path it refuses; a gap names a path only processing can resolve.