personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

+165 -27
+11 -7
solstone/apps/backup/copy.py
··· 46 46 DESTRUCTIVE_CAPTION = ( 47 47 "this deletes all your backup data. no new backups will be created." 48 48 ) 49 - TEARDOWN_GATE_LEAD = "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." 50 - 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." 51 - TEARDOWN_GATE_ZERO_LEAD = "no offloaded recordings exist only in this backup right now." 49 + TEARDOWN_GATE_LEAD = "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." 50 + 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." 51 + TEARDOWN_GATE_ZERO_LEAD = ( 52 + "nothing exists only in this backup right now. every day is still on your device." 53 + ) 52 54 TEARDOWN_CONFIRM_PHRASE = "delete" 53 55 TEARDOWN_CONFIRM_PROMPT = "type delete to confirm" 54 56 TEARDOWN_RESTORE_FIRST_ACTION = "restore everything first" ··· 167 169 "a large restore can take a while. you can leave this page open while it runs." 168 170 ) 169 171 OFFLOAD_TITLE = "media offload" 170 - 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." 172 + 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." 171 173 OFFLOAD_STALLED_LEAD = ( 172 174 "offload is paused: your backup isn't working. nothing has been deleted." 173 175 ) ··· 175 177 OFFLOAD_RESTORE_EXPECTATION = ( 176 178 "restoring {size} from your backup — a large restore can take a while." 177 179 ) 178 - OFFLOAD_DISABLE_NOTE = "offloading stops. recordings already in your backup stay there — protected and restorable." 180 + OFFLOAD_DISABLE_NOTE = ( 181 + "this stops. days already in your backup stay there — protected and restorable." 182 + ) 179 183 OFFLOAD_UNAVAILABLE_LEAD = "can't read offload status right now." 180 184 OFFLOAD_ACTION_ERROR = ( 181 185 "media offload couldn't finish. check backup setup, then try again." ··· 213 217 "saved": "saved", 214 218 "empty_days": "no offloaded media yet.", 215 219 "show_all_days": "show all {count} days", 216 - "degraded": "some offload ledger entries could not be read.", 220 + "degraded": "some of the record of what's in your backup couldn't be read. these days may hold more than shown.", 217 221 } 218 222 OFFLOAD_STALL_REASON_LABELS = { 219 223 "backup_not_ready": "encrypted backup needs to finish setup before media offload can run.", ··· 232 236 "backup_not_ready": "encrypted backup is not ready to restore media.", 233 237 "failed": "media restore could not finish.", 234 238 "insufficient_free_space": "this device needs more free space before restoring media.", 235 - "ledger_degraded": "media restore is paused because the offload ledger needs repair.", 239 + "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.", 236 240 "locked": "media restore is waiting for backup maintenance to finish.", 237 241 "missing_file_after_restore": "media restore finished, but a file was still missing.", 238 242 "nothing_to_restore": "nothing to restore for that day.",
+12 -8
solstone/apps/backup/static/backup.js
··· 77 77 "management": { 78 78 "destructive_action": "turn off & delete backup", 79 79 "destructive_caption": "this deletes all your backup data. no new backups will be created.", 80 - "teardown_gate_lead": "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever.", 81 - "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.", 82 - "teardown_gate_zero_lead": "no offloaded recordings exist only in this backup right now.", 80 + "teardown_gate_lead": "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever.", 81 + "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.", 82 + "teardown_gate_zero_lead": "nothing exists only in this backup right now. every day is still on your device.", 83 83 "teardown_confirm_phrase": "delete", 84 84 "teardown_confirm_prompt": "type delete to confirm", 85 85 "teardown_restore_first_action": "restore everything first", ··· 110 110 }, 111 111 "offload": { 112 112 "title": "media offload", 113 - "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.", 113 + "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.", 114 114 "stalled_lead": "offload is paused: your backup isn't working. nothing has been deleted.", 115 115 "backup_only_label": "in your backup", 116 116 "restore_expectation": "restoring {size} from your backup — a large restore can take a while.", 117 - "disable_note": "offloading stops. recordings already in your backup stay there — protected and restorable.", 117 + "disable_note": "this stops. days already in your backup stay there — protected and restorable.", 118 118 "unavailable_lead": "can't read offload status right now.", 119 119 "action_error": "media offload couldn't finish. check backup setup, then try again.", 120 120 "invalid_limits": "enter a positive number for each limit, then save again.", ··· 146 146 "saved": "saved", 147 147 "empty_days": "no offloaded media yet.", 148 148 "show_all_days": "show all {count} days", 149 - "degraded": "some offload ledger entries could not be read." 149 + "degraded": "some of the record of what's in your backup couldn't be read. these days may hold more than shown." 150 150 }, 151 151 "stall_reason_labels": { 152 152 "backup_not_ready": "encrypted backup needs to finish setup before media offload can run.", ··· 165 165 "backup_not_ready": "encrypted backup is not ready to restore media.", 166 166 "failed": "media restore could not finish.", 167 167 "insufficient_free_space": "this device needs more free space before restoring media.", 168 - "ledger_degraded": "media restore is paused because the offload ledger needs repair.", 168 + "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.", 169 169 "locked": "media restore is waiting for backup maintenance to finish.", 170 170 "missing_file_after_restore": "media restore finished, but a file was still missing.", 171 171 "nothing_to_restore": "nothing to restore for that day.", ··· 868 868 } 869 869 banner.hidden = false; 870 870 setText('[data-operation-phase]', labelForPhase(operation.phase)); 871 - setText('[data-operation-error]', reasonLabel(operation.reason_code)); 871 + const errorLabel = 872 + operation.kind === 'offload_restore' 873 + ? offloadRestoreReasonLabel(operation.reason_code) 874 + : reasonLabel(operation.reason_code); 875 + setText('[data-operation-error]', errorLabel); 872 876 } 873 877 874 878 function renderStatus() {
+37 -6
solstone/apps/backup/tests/test_copy.py
··· 23 23 r"(?:observes|watches|sees|captures|records|monitors|tracks|collects)\b", 24 24 re.IGNORECASE, 25 25 ) 26 + _SURVEILLANCE_NOUN = re.compile( 27 + r"\b(?:recording|capture|observation|observer)s?\b", re.IGNORECASE 28 + ) 26 29 27 30 28 31 def _backup_js_text() -> str: ··· 78 81 ) 79 82 assert ( 80 83 payload["management"]["teardown_gate_lead"] 81 - == "{days} days of recordings ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." 84 + == "{days} days of your journal ({size}) exist only in this backup. deleting the backup deletes them everywhere, forever." 82 85 ) 83 86 assert "{days}" in payload["management"]["teardown_gate_lead"] 84 87 assert "{size}" in payload["management"]["teardown_gate_lead"] 85 88 assert ( 86 89 payload["management"]["teardown_gate_unavailable_lead"] 87 - == "can't verify what exists only in this backup right now. deleting the backup may destroy recordings that exist nowhere else." 90 + == "can't verify what exists only in this backup right now. deleting the backup may destroy days of your journal that exist nowhere else." 88 91 ) 89 92 assert ( 90 93 payload["management"]["teardown_gate_zero_lead"] 91 - == "no offloaded recordings exist only in this backup right now." 94 + == "nothing exists only in this backup right now. every day is still on your device." 92 95 ) 93 96 assert payload["management"]["teardown_confirm_phrase"] == TEARDOWN_CONFIRM_PHRASE 94 97 assert payload["management"]["teardown_confirm_prompt"] == TEARDOWN_CONFIRM_PROMPT ··· 129 132 ) 130 133 assert ( 131 134 offload["stakes"] 132 - == "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." 135 + == "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." 133 136 ) 134 137 assert ( 135 138 offload["stalled_lead"] ··· 143 146 assert "{size}" in offload["restore_expectation"] 144 147 assert ( 145 148 offload["disable_note"] 146 - == "offloading stops. recordings already in your backup stay there — protected and restorable." 149 + == "this stops. days already in your backup stay there — protected and restorable." 147 150 ) 148 151 assert offload["unavailable_lead"] == "can't read offload status right now." 149 152 assert ( ··· 160 163 161 164 def test_backup_copy_has_no_surveillance_verb_subjects() -> None: 162 165 assert _VERB_SUBJECT.search("sol records your day") 163 - assert _VERB_SUBJECT.search("your older recordings") is None 164 166 165 167 offenders = [value for value in backup_copy_values() if _VERB_SUBJECT.search(value)] 168 + assert offenders == [] 169 + 170 + 171 + def test_backup_copy_has_no_surveillance_nouns() -> None: 172 + assert _SURVEILLANCE_NOUN.search("recording") 173 + assert _SURVEILLANCE_NOUN.search("recordings") 174 + assert _SURVEILLANCE_NOUN.search("Recording") 175 + assert _SURVEILLANCE_NOUN.search("Capture") 176 + 177 + values: list[str] = [] 178 + 179 + def visit(value: object) -> None: 180 + if isinstance(value, str): 181 + values.append(value) 182 + elif isinstance(value, dict): 183 + for item in value.values(): 184 + visit(item) 185 + elif isinstance(value, list): 186 + for item in value: 187 + visit(item) 188 + 189 + visit({"safe": ["ok", {"nested": "hidden observer copy"}]}) 190 + assert [value for value in values if _SURVEILLANCE_NOUN.search(value)] == [ 191 + "hidden observer copy" 192 + ] 193 + 194 + offenders = [ 195 + value for value in backup_copy_values() if _SURVEILLANCE_NOUN.search(value) 196 + ] 166 197 assert offenders == [] 167 198 168 199
+8
solstone/apps/backup/tests/test_layout.py
··· 451 451 ) 452 452 assert "labelForPhase('restoring')" not in offload_restore_day 453 453 454 + render_operation = js[ 455 + js.index("function renderOperation()") : js.index("function renderStatus()") 456 + ] 457 + assert "operation.kind === 'offload_restore'" in render_operation 458 + assert "offloadRestoreReasonLabel(operation.reason_code)" in render_operation 459 + assert "reasonLabel(operation.reason_code)" in render_operation 460 + assert "error_intro" not in render_operation 461 + 454 462 budget_gb = 37 455 463 floor_gb = 23 456 464 assert budget_gb != floor_gb
-1
solstone/apps/backup/tests/test_routes.py
··· 1162 1162 "account", 1163 1163 "capture", 1164 1164 "watch", 1165 - "record", 1166 1165 "monitor", 1167 1166 "track", 1168 1167 "collect",
+5 -5
solstone/think/offload_ledger.py
··· 77 77 78 78 @property 79 79 def degraded(self) -> bool: 80 - return bool(self.unreadable_ledgers) 80 + return bool(self.unreadable_ledgers) or self.skipped_records > 0 81 81 82 82 83 83 @dataclass(frozen=True) ··· 92 92 93 93 @property 94 94 def degraded(self) -> bool: 95 - return bool(self.unreadable_ledgers) 95 + return bool(self.unreadable_ledgers) or self.skipped_records > 0 96 96 97 97 98 98 @dataclass(frozen=True) ··· 107 107 108 108 @property 109 109 def degraded(self) -> bool: 110 - return bool(self.unreadable_ledgers) 110 + return bool(self.unreadable_ledgers) or self.skipped_records > 0 111 111 112 112 113 113 @dataclass(frozen=True) ··· 207 207 stream, 208 208 segment, 209 209 current, 210 - skipped_records=0, 211 - unreadable_ledgers=(), 210 + skipped_records=read.skipped_records, 211 + unreadable_ledgers=read.unreadable_ledgers, 212 212 ) 213 213 for (key_day, stream, segment), current in sorted(states.items()) 214 214 if key_day == day
+52
tests/test_offload_ledger.py
··· 295 295 assert any("malformed" in record.message for record in caplog.records) 296 296 297 297 298 + def test_malformed_line_marks_all_summary_views_degraded( 299 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch 300 + ) -> None: 301 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 302 + ledger_path = ledger_path_for_day(DAY) 303 + ledger_path.parent.mkdir(parents=True) 304 + ledger_path.write_text( 305 + "\n".join( 306 + [ 307 + json.dumps( 308 + _offload_record( 309 + day=DAY, 310 + stream=STREAM, 311 + segment="120000_300", 312 + snapshot_id="snap-1", 313 + size=10, 314 + ) 315 + ), 316 + "{bad", 317 + json.dumps( 318 + _offload_record( 319 + day=DAY, 320 + stream=STREAM, 321 + segment="121000_300", 322 + snapshot_id="snap-2", 323 + size=20, 324 + sha256=SHA_B, 325 + ) 326 + ), 327 + ] 328 + ) 329 + + "\n", 330 + encoding="utf-8", 331 + ) 332 + 333 + day = summarize_day(DAY) 334 + assert day.degraded is True 335 + assert day.skipped_records == 1 336 + assert day.offloaded_bytes == 30 337 + assert all(seg.degraded is True for seg in day.segments) 338 + 339 + segment = summarize_segment(DAY, STREAM, "120000_300") 340 + assert segment.degraded is True 341 + assert segment.skipped_records == 1 342 + 343 + journal = summarize_journal() 344 + assert journal.degraded is True 345 + assert journal.skipped_records == 1 346 + assert journal.offloaded_bytes == 30 347 + assert all(seg.degraded is True for day in journal.days for seg in day.segments) 348 + 349 + 298 350 def test_null_time_record_is_skipped_not_fabricated( 299 351 tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture 300 352 ) -> None:
+40
tests/test_offload_restore.py
··· 740 740 assert degraded.reason == "ledger_degraded" 741 741 742 742 743 + def test_skipped_record_degrades_and_refuses_restore( 744 + tmp_path: Path, 745 + monkeypatch: pytest.MonkeyPatch, 746 + ) -> None: 747 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 748 + _write_config(tmp_path, _backup_config()) 749 + _segment_dir(tmp_path) 750 + _seed_ledger() 751 + ledger_path = tmp_path / "health" / "offload" / f"{DAY}.jsonl" 752 + with open(ledger_path, "a", encoding="utf-8") as handle: 753 + handle.write("{bad\n") 754 + 755 + status = offload_restore.build_offload_status() 756 + 757 + assert status["backup_only"]["degraded"] is True 758 + assert status["days"][0]["degraded"] is True 759 + 760 + run_restic = Mock() 761 + monkeypatch.setattr(offload_restore, "run_restic", run_restic) 762 + monkeypatch.setattr(offload_restore, "device_free_bytes", lambda: 5_000_000_000) 763 + 764 + day_result = offload_restore.restore_day(DAY) 765 + 766 + assert day_result.status == "error" 767 + assert day_result.reason == "ledger_degraded" 768 + run_restic.assert_not_called() 769 + 770 + from solstone.think.backup.state import get_backup_config 771 + 772 + last_restore = get_backup_config()["last_restore"] 773 + assert last_restore["reason"] == "ledger_degraded" 774 + assert last_restore["status"] == "error" 775 + 776 + all_result = offload_restore.restore_all() 777 + 778 + assert all_result.status == "error" 779 + assert all_result.reason == "ledger_degraded" 780 + run_restic.assert_not_called() 781 + 782 + 743 783 def test_operated_restore_reports_rclone_unavailable( 744 784 tmp_path: Path, 745 785 monkeypatch: pytest.MonkeyPatch,