From 27d2919853766e56049e7095d350bd7dbbdcb0eb Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 17 Jul 2026 19:11:48 -0600 Subject: [PATCH] fix(backup): degrade offload summaries on skipped ledger records A malformed/truncated offload-ledger line was counted as skipped_records but did not mark a summary degraded, so a corrupted ledger could present a clean zero to teardown/restore surfaces. Degrade at segment/day/journal levels on skipped_records > 0, propagate into segments nested under a degraded day, give ledger_degraded its own restore guidance, route the offload_restore operation banner through the offload-restore reason map (never the recovery-key error_intro), swap five owner-copy `recordings` strings, and strengthen the copy lint to a noun-form surveillance guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/backup/copy.py | 18 +++++--- solstone/apps/backup/static/backup.js | 20 +++++---- solstone/apps/backup/tests/test_copy.py | 43 ++++++++++++++++--- solstone/apps/backup/tests/test_layout.py | 8 ++++ solstone/apps/backup/tests/test_routes.py | 1 - solstone/think/offload_ledger.py | 10 ++--- tests/test_offload_ledger.py | 52 +++++++++++++++++++++++ tests/test_offload_restore.py | 40 +++++++++++++++++ 8 files changed, 165 insertions(+), 27 deletions(-) diff --git a/solstone/apps/backup/copy.py b/solstone/apps/backup/copy.py index c40f6c450..9874fc24c 100644 --- a/solstone/apps/backup/copy.py +++ b/solstone/apps/backup/copy.py @@ -46,9 +46,11 @@ DESTRUCTIVE_ACTION = "turn off & delete backup" DESTRUCTIVE_CAPTION = ( "this deletes all your backup data. no new backups will be created." ) -TEARDOWN_GATE_LEAD = "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." -TEARDOWN_GATE_UNAVAILABLE_LEAD = "can't verify what exists only in this backup right now. deleting the backup may destroy recordings that exist nowhere else." -TEARDOWN_GATE_ZERO_LEAD = "no offloaded recordings exist only in this backup right now." +TEARDOWN_GATE_LEAD = "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." +TEARDOWN_GATE_UNAVAILABLE_LEAD = "can't verify what exists only in this backup right now. deleting the backup may destroy days of your journal that exist nowhere else." +TEARDOWN_GATE_ZERO_LEAD = ( + "nothing exists only in this backup right now. every day is still on your device." +) TEARDOWN_CONFIRM_PHRASE = "delete" TEARDOWN_CONFIRM_PROMPT = "type delete to confirm" TEARDOWN_RESTORE_FIRST_ACTION = "restore everything first" @@ -167,7 +169,7 @@ RESTORE_EXPECTATION = ( "a large restore can take a while. you can leave this page open while it runs." ) OFFLOAD_TITLE = "media offload" -OFFLOAD_STAKES = "after offload, your backup holds the only copy of your older recordings. if you lose your recovery key, no one can recover them — not even sol pbc." +OFFLOAD_STAKES = "after this, your backup holds the only copy of your older days. if you lose your recovery key, no one can recover them — not even sol pbc." OFFLOAD_STALLED_LEAD = ( "offload is paused: your backup isn't working. nothing has been deleted." ) @@ -175,7 +177,9 @@ OFFLOAD_BACKUP_ONLY_LABEL = "in your backup" OFFLOAD_RESTORE_EXPECTATION = ( "restoring {size} from your backup — a large restore can take a while." ) -OFFLOAD_DISABLE_NOTE = "offloading stops. recordings already in your backup stay there — protected and restorable." +OFFLOAD_DISABLE_NOTE = ( + "this stops. days already in your backup stay there — protected and restorable." +) OFFLOAD_UNAVAILABLE_LEAD = "can't read offload status right now." OFFLOAD_ACTION_ERROR = ( "media offload couldn't finish. check backup setup, then try again." @@ -213,7 +217,7 @@ OFFLOAD_MESSAGES = { "saved": "saved", "empty_days": "no offloaded media yet.", "show_all_days": "show all {count} days", - "degraded": "some offload ledger entries could not be read.", + "degraded": "some of the record of what's in your backup couldn't be read. these days may hold more than shown.", } OFFLOAD_STALL_REASON_LABELS = { "backup_not_ready": "encrypted backup needs to finish setup before media offload can run.", @@ -232,7 +236,7 @@ OFFLOAD_RESTORE_REASON_LABELS = { "backup_not_ready": "encrypted backup is not ready to restore media.", "failed": "media restore could not finish.", "insufficient_free_space": "this device needs more free space before restoring media.", - "ledger_degraded": "media restore is paused because the offload ledger needs repair.", + "ledger_degraded": "some of the record of what's in your backup couldn't be read, so a restore can't be trusted to be complete. try again after the next backup runs.", "locked": "media restore is waiting for backup maintenance to finish.", "missing_file_after_restore": "media restore finished, but a file was still missing.", "nothing_to_restore": "nothing to restore for that day.", diff --git a/solstone/apps/backup/static/backup.js b/solstone/apps/backup/static/backup.js index b9ef8fb3e..d74d656e2 100644 --- a/solstone/apps/backup/static/backup.js +++ b/solstone/apps/backup/static/backup.js @@ -77,9 +77,9 @@ "management": { "destructive_action": "turn off & delete backup", "destructive_caption": "this deletes all your backup data. no new backups will be created.", - "teardown_gate_lead": "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever.", - "teardown_gate_unavailable_lead": "can't verify what exists only in this backup right now. deleting the backup may destroy recordings that exist nowhere else.", - "teardown_gate_zero_lead": "no offloaded recordings exist only in this backup right now.", + "teardown_gate_lead": "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever.", + "teardown_gate_unavailable_lead": "can't verify what exists only in this backup right now. deleting the backup may destroy days of your journal that exist nowhere else.", + "teardown_gate_zero_lead": "nothing exists only in this backup right now. every day is still on your device.", "teardown_confirm_phrase": "delete", "teardown_confirm_prompt": "type delete to confirm", "teardown_restore_first_action": "restore everything first", @@ -110,11 +110,11 @@ }, "offload": { "title": "media offload", - "stakes": "after offload, your backup holds the only copy of your older recordings. if you lose your recovery key, no one can recover them — not even sol pbc.", + "stakes": "after this, your backup holds the only copy of your older days. if you lose your recovery key, no one can recover them — not even sol pbc.", "stalled_lead": "offload is paused: your backup isn't working. nothing has been deleted.", "backup_only_label": "in your backup", "restore_expectation": "restoring {size} from your backup — a large restore can take a while.", - "disable_note": "offloading stops. recordings already in your backup stay there — protected and restorable.", + "disable_note": "this stops. days already in your backup stay there — protected and restorable.", "unavailable_lead": "can't read offload status right now.", "action_error": "media offload couldn't finish. check backup setup, then try again.", "invalid_limits": "enter a positive number for each limit, then save again.", @@ -146,7 +146,7 @@ "saved": "saved", "empty_days": "no offloaded media yet.", "show_all_days": "show all {count} days", - "degraded": "some offload ledger entries could not be read." + "degraded": "some of the record of what's in your backup couldn't be read. these days may hold more than shown." }, "stall_reason_labels": { "backup_not_ready": "encrypted backup needs to finish setup before media offload can run.", @@ -165,7 +165,7 @@ "backup_not_ready": "encrypted backup is not ready to restore media.", "failed": "media restore could not finish.", "insufficient_free_space": "this device needs more free space before restoring media.", - "ledger_degraded": "media restore is paused because the offload ledger needs repair.", + "ledger_degraded": "some of the record of what's in your backup couldn't be read, so a restore can't be trusted to be complete. try again after the next backup runs.", "locked": "media restore is waiting for backup maintenance to finish.", "missing_file_after_restore": "media restore finished, but a file was still missing.", "nothing_to_restore": "nothing to restore for that day.", @@ -868,7 +868,11 @@ } banner.hidden = false; setText('[data-operation-phase]', labelForPhase(operation.phase)); - setText('[data-operation-error]', reasonLabel(operation.reason_code)); + const errorLabel = + operation.kind === 'offload_restore' + ? offloadRestoreReasonLabel(operation.reason_code) + : reasonLabel(operation.reason_code); + setText('[data-operation-error]', errorLabel); } function renderStatus() { diff --git a/solstone/apps/backup/tests/test_copy.py b/solstone/apps/backup/tests/test_copy.py index f4e43b19b..6e580e37a 100644 --- a/solstone/apps/backup/tests/test_copy.py +++ b/solstone/apps/backup/tests/test_copy.py @@ -23,6 +23,9 @@ _VERB_SUBJECT = re.compile( r"(?:observes|watches|sees|captures|records|monitors|tracks|collects)\b", re.IGNORECASE, ) +_SURVEILLANCE_NOUN = re.compile( + r"\b(?:recording|capture|observation|observer)s?\b", re.IGNORECASE +) def _backup_js_text() -> str: @@ -78,17 +81,17 @@ def test_backup_copy_verbatim_strings() -> None: ) assert ( payload["management"]["teardown_gate_lead"] - == "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." + == "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." ) assert "{days}" in payload["management"]["teardown_gate_lead"] assert "{size}" in payload["management"]["teardown_gate_lead"] assert ( payload["management"]["teardown_gate_unavailable_lead"] - == "can't verify what exists only in this backup right now. deleting the backup may destroy recordings that exist nowhere else." + == "can't verify what exists only in this backup right now. deleting the backup may destroy days of your journal that exist nowhere else." ) assert ( payload["management"]["teardown_gate_zero_lead"] - == "no offloaded recordings exist only in this backup right now." + == "nothing exists only in this backup right now. every day is still on your device." ) assert payload["management"]["teardown_confirm_phrase"] == TEARDOWN_CONFIRM_PHRASE assert payload["management"]["teardown_confirm_prompt"] == TEARDOWN_CONFIRM_PROMPT @@ -129,7 +132,7 @@ def test_backup_copy_verbatim_strings() -> None: ) assert ( offload["stakes"] - == "after offload, your backup holds the only copy of your older recordings. if you lose your recovery key, no one can recover them — not even sol pbc." + == "after this, your backup holds the only copy of your older days. if you lose your recovery key, no one can recover them — not even sol pbc." ) assert ( offload["stalled_lead"] @@ -143,7 +146,7 @@ def test_backup_copy_verbatim_strings() -> None: assert "{size}" in offload["restore_expectation"] assert ( offload["disable_note"] - == "offloading stops. recordings already in your backup stay there — protected and restorable." + == "this stops. days already in your backup stay there — protected and restorable." ) assert offload["unavailable_lead"] == "can't read offload status right now." assert ( @@ -160,12 +163,40 @@ def test_backup_copy_verbatim_strings() -> None: def test_backup_copy_has_no_surveillance_verb_subjects() -> None: assert _VERB_SUBJECT.search("sol records your day") - assert _VERB_SUBJECT.search("your older recordings") is None offenders = [value for value in backup_copy_values() if _VERB_SUBJECT.search(value)] assert offenders == [] +def test_backup_copy_has_no_surveillance_nouns() -> None: + assert _SURVEILLANCE_NOUN.search("recording") + assert _SURVEILLANCE_NOUN.search("recordings") + assert _SURVEILLANCE_NOUN.search("Recording") + assert _SURVEILLANCE_NOUN.search("Capture") + + values: list[str] = [] + + def visit(value: object) -> None: + if isinstance(value, str): + values.append(value) + elif isinstance(value, dict): + for item in value.values(): + visit(item) + elif isinstance(value, list): + for item in value: + visit(item) + + visit({"safe": ["ok", {"nested": "hidden observer copy"}]}) + assert [value for value in values if _SURVEILLANCE_NOUN.search(value)] == [ + "hidden observer copy" + ] + + offenders = [ + value for value in backup_copy_values() if _SURVEILLANCE_NOUN.search(value) + ] + assert offenders == [] + + def test_offload_reason_copy_covers_closed_vocabularies() -> None: offload = backup_copy_payload()["offload"] stall = offload["stall_reason_labels"] diff --git a/solstone/apps/backup/tests/test_layout.py b/solstone/apps/backup/tests/test_layout.py index 9a80d6a4e..82dd1b11b 100644 --- a/solstone/apps/backup/tests/test_layout.py +++ b/solstone/apps/backup/tests/test_layout.py @@ -451,6 +451,14 @@ def test_offload_js_source_contracts() -> None: ) assert "labelForPhase('restoring')" not in offload_restore_day + render_operation = js[ + js.index("function renderOperation()") : js.index("function renderStatus()") + ] + assert "operation.kind === 'offload_restore'" in render_operation + assert "offloadRestoreReasonLabel(operation.reason_code)" in render_operation + assert "reasonLabel(operation.reason_code)" in render_operation + assert "error_intro" not in render_operation + budget_gb = 37 floor_gb = 23 assert budget_gb != floor_gb diff --git a/solstone/apps/backup/tests/test_routes.py b/solstone/apps/backup/tests/test_routes.py index 1ba21c34b..77c4401cc 100644 --- a/solstone/apps/backup/tests/test_routes.py +++ b/solstone/apps/backup/tests/test_routes.py @@ -1162,7 +1162,6 @@ def test_forbidden_terms_absent_from_backup_surfaces(backup_env, monkeypatch) -> "account", "capture", "watch", - "record", "monitor", "track", "collect", diff --git a/solstone/think/offload_ledger.py b/solstone/think/offload_ledger.py index 7d1ae4ead..60d7b5f8b 100644 --- a/solstone/think/offload_ledger.py +++ b/solstone/think/offload_ledger.py @@ -77,7 +77,7 @@ class SegmentOffloadSummary: @property def degraded(self) -> bool: - return bool(self.unreadable_ledgers) + return bool(self.unreadable_ledgers) or self.skipped_records > 0 @dataclass(frozen=True) @@ -92,7 +92,7 @@ class DayOffloadSummary: @property def degraded(self) -> bool: - return bool(self.unreadable_ledgers) + return bool(self.unreadable_ledgers) or self.skipped_records > 0 @dataclass(frozen=True) @@ -107,7 +107,7 @@ class JournalOffloadSummary: @property def degraded(self) -> bool: - return bool(self.unreadable_ledgers) + return bool(self.unreadable_ledgers) or self.skipped_records > 0 @dataclass(frozen=True) @@ -207,8 +207,8 @@ def summarize_day(day: str) -> DayOffloadSummary: stream, segment, current, - skipped_records=0, - unreadable_ledgers=(), + skipped_records=read.skipped_records, + unreadable_ledgers=read.unreadable_ledgers, ) for (key_day, stream, segment), current in sorted(states.items()) if key_day == day diff --git a/tests/test_offload_ledger.py b/tests/test_offload_ledger.py index c9ea8a933..6724b9e66 100644 --- a/tests/test_offload_ledger.py +++ b/tests/test_offload_ledger.py @@ -295,6 +295,58 @@ def test_malformed_line_warns_counts_skipped_and_keeps_valid_totals( assert any("malformed" in record.message for record in caplog.records) +def test_malformed_line_marks_all_summary_views_degraded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + ledger_path = ledger_path_for_day(DAY) + ledger_path.parent.mkdir(parents=True) + ledger_path.write_text( + "\n".join( + [ + json.dumps( + _offload_record( + day=DAY, + stream=STREAM, + segment="120000_300", + snapshot_id="snap-1", + size=10, + ) + ), + "{bad", + json.dumps( + _offload_record( + day=DAY, + stream=STREAM, + segment="121000_300", + snapshot_id="snap-2", + size=20, + sha256=SHA_B, + ) + ), + ] + ) + + "\n", + encoding="utf-8", + ) + + day = summarize_day(DAY) + assert day.degraded is True + assert day.skipped_records == 1 + assert day.offloaded_bytes == 30 + assert all(seg.degraded is True for seg in day.segments) + + segment = summarize_segment(DAY, STREAM, "120000_300") + assert segment.degraded is True + assert segment.skipped_records == 1 + + journal = summarize_journal() + assert journal.degraded is True + assert journal.skipped_records == 1 + assert journal.offloaded_bytes == 30 + assert all(seg.degraded is True for day in journal.days for seg in day.segments) + + def test_null_time_record_is_skipped_not_fabricated( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_offload_restore.py b/tests/test_offload_restore.py index 2be8836c9..5b5b65672 100644 --- a/tests/test_offload_restore.py +++ b/tests/test_offload_restore.py @@ -740,6 +740,46 @@ def test_restore_tool_unavailable_and_ledger_degraded_reasons( assert degraded.reason == "ledger_degraded" +def test_skipped_record_degrades_and_refuses_restore( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + _write_config(tmp_path, _backup_config()) + _segment_dir(tmp_path) + _seed_ledger() + ledger_path = tmp_path / "health" / "offload" / f"{DAY}.jsonl" + with open(ledger_path, "a", encoding="utf-8") as handle: + handle.write("{bad\n") + + status = offload_restore.build_offload_status() + + assert status["backup_only"]["degraded"] is True + assert status["days"][0]["degraded"] is True + + run_restic = Mock() + monkeypatch.setattr(offload_restore, "run_restic", run_restic) + monkeypatch.setattr(offload_restore, "device_free_bytes", lambda: 5_000_000_000) + + day_result = offload_restore.restore_day(DAY) + + assert day_result.status == "error" + assert day_result.reason == "ledger_degraded" + run_restic.assert_not_called() + + from solstone.think.backup.state import get_backup_config + + last_restore = get_backup_config()["last_restore"] + assert last_restore["reason"] == "ledger_degraded" + assert last_restore["status"] == "error" + + all_result = offload_restore.restore_all() + + assert all_result.status == "error" + assert all_result.reason == "ledger_degraded" + run_restic.assert_not_called() + + def test_operated_restore_reports_rclone_unavailable( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -- 2.51.2